From a3a46dda2709539fdd2a6680ca538d1253c22e01 Mon Sep 17 00:00:00 2001
From: Tomas Cohen Arazi <tomascohen@theke.io>
Date: Wed, 24 Jan 2024 16:17:17 -0300
Subject: [PATCH] Bug 35919: Add /record_sources endpoints

This patch introduces endpoints for managing record sources. This is
done on top of Koha::RecordSource(s) following the current coding style.

To test:
1. Apply this patch
2. Run:
   $ ktd --shell
  k$ prove t/db_dependent/api/v1/record_sources.t
=> SUCCESS: Tests pass!
3. Sign off :-D

Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
---
 Koha/REST/V1/RecordSources.pm                 | 143 +++++++++
 api/v1/swagger/definitions/record_source.yaml |  16 +
 api/v1/swagger/paths/record_sources.yaml      | 246 ++++++++++++++
 api/v1/swagger/swagger.yaml                   |  15 +
 t/db_dependent/api/v1/record_sources.t        | 301 ++++++++++++++++++
 5 files changed, 721 insertions(+)
 create mode 100644 Koha/REST/V1/RecordSources.pm
 create mode 100644 api/v1/swagger/definitions/record_source.yaml
 create mode 100644 api/v1/swagger/paths/record_sources.yaml
 create mode 100755 t/db_dependent/api/v1/record_sources.t

diff --git a/Koha/REST/V1/RecordSources.pm b/Koha/REST/V1/RecordSources.pm
new file mode 100644
index 00000000000..522b6943d38
--- /dev/null
+++ b/Koha/REST/V1/RecordSources.pm
@@ -0,0 +1,143 @@
+package Koha::REST::V1::RecordSources;
+
+# Copyright 2023 Theke Solutions
+#
+# This file is part of Koha.
+#
+# Koha is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# Koha is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Koha; if not, see <http://www.gnu.org/licenses>.
+
+use Modern::Perl;
+
+use Mojo::Base 'Mojolicious::Controller';
+
+use Koha::RecordSources;
+
+use Try::Tiny qw( catch try );
+
+=head1 API
+
+=head2 Methods
+
+=head3 list
+
+=cut
+
+sub list {
+    my $c = shift->openapi->valid_input or return;
+
+    return try {
+        return $c->render(
+            status  => 200,
+            openapi => $c->objects->search( Koha::RecordSources->new )
+        );
+    } catch {
+        $c->unhandled_exception($_);
+    };
+}
+
+=head3 get
+
+=cut
+
+sub get {
+    my $c = shift->openapi->valid_input or return;
+
+    return try {
+        my $source = $c->objects->find( Koha::RecordSources->new, $c->param('record_source_id') );
+        unless ($source) {
+            return $c->render(
+                status  => 404,
+                openapi => { error => "Object not found" }
+            );
+        }
+
+        return $c->render( status => 200, openapi => $source );
+    } catch {
+        $c->unhandled_exception($_);
+    };
+}
+
+=head3 add
+
+=cut
+
+sub add {
+    my $c = shift->openapi->valid_input or return;
+
+    return try {
+        my $source = Koha::RecordSource->new_from_api( $c->req->json );
+        $source->store;
+        $c->res->headers->location( $c->req->url->to_string . '/' . $source->record_source_id );
+        return $c->render(
+            status  => 201,
+            openapi => $c->objects->to_api($source)
+        );
+    } catch {
+        $c->unhandled_exception($_);
+    };
+}
+
+=head3 update
+
+=cut
+
+sub update {
+    my $c = shift->openapi->valid_input or return;
+
+    my $source = $c->objects->find_rs( Koha::RecordSources->new, $c->param('record_source_id') );
+
+    unless ($source) {
+        return $c->render(
+            status  => 404,
+            openapi => { error => "Object not found" }
+        );
+    }
+
+    return try {
+        $source->set_from_api( $c->req->json )->store;
+        $source->discard_changes;
+        return $c->render( status => 200, openapi => $c->objects->to_api($source) );
+    } catch {
+        $c->unhandled_exception($_);
+    };
+}
+
+=head3 delete
+
+=cut
+
+sub delete {
+    my $c = shift->openapi->valid_input or return;
+
+    my $source = $c->objects->find_rs( Koha::RecordSources->new, $c->param('record_source_id') );
+
+    unless ($source) {
+        return $c->render(
+            status  => 404,
+            openapi => { error => "Object not found" }
+        );
+    }
+
+    return try {
+        $source->delete;
+        return $c->render(
+            status  => 204,
+            openapi => q{}
+        );
+    } catch {
+        $c->unhandled_exception($_);
+    };
+}
+
+1;
diff --git a/api/v1/swagger/definitions/record_source.yaml b/api/v1/swagger/definitions/record_source.yaml
new file mode 100644
index 00000000000..dcf7865f73e
--- /dev/null
+++ b/api/v1/swagger/definitions/record_source.yaml
@@ -0,0 +1,16 @@
+---
+type: object
+properties:
+  record_source_id:
+    type: integer
+    description: Internally assigned record source identifier
+    readOnly: true
+  name:
+    description: Record source name
+    type: string
+  can_be_edited:
+    description: If records from this source can be edited
+    type: boolean
+additionalProperties: false
+required:
+  - name
diff --git a/api/v1/swagger/paths/record_sources.yaml b/api/v1/swagger/paths/record_sources.yaml
new file mode 100644
index 00000000000..c1b1c182d8e
--- /dev/null
+++ b/api/v1/swagger/paths/record_sources.yaml
@@ -0,0 +1,246 @@
+/record_sources:
+  get:
+    x-mojo-to: RecordSources#list
+    operationId: listRecordSources
+    summary: List record sources
+    tags:
+      - record_sources
+    parameters:
+      - $ref: "../swagger.yaml#/parameters/match"
+      - $ref: "../swagger.yaml#/parameters/order_by"
+      - $ref: "../swagger.yaml#/parameters/page"
+      - $ref: "../swagger.yaml#/parameters/per_page"
+      - $ref: "../swagger.yaml#/parameters/q_param"
+      - $ref: "../swagger.yaml#/parameters/q_body"
+      - $ref: "../swagger.yaml#/parameters/request_id_header"
+    consumes:
+      - application/json
+    produces:
+      - application/json
+    responses:
+      "200":
+        description: A list of record sources
+        schema:
+          type: array
+          items:
+            $ref: "../swagger.yaml#/definitions/record_source"
+      "400":
+        description: Missing or wrong parameters
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "401":
+        description: Authentication required
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "403":
+        description: Not allowed
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "500":
+        description: |
+          Internal server error. Possible `error_code` attribute values:
+
+          * `internal_server_error`
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "503":
+        description: Under maintenance
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+    x-koha-authorization:
+      permissions:
+        parameters: manage_record_sources
+  post:
+    x-mojo-to: RecordSources#add
+    operationId: addRecordSources
+    summary: Add a record source
+    tags:
+      - record_sources
+    parameters:
+      - name: body
+        in: body
+        description: A JSON object containing informations about the new record source
+        required: true
+        schema:
+          $ref: "../swagger.yaml#/definitions/record_source"
+    consumes:
+      - application/json
+    produces:
+      - application/json
+    responses:
+      "201":
+        description: A record source
+        schema:
+          $ref: "../swagger.yaml#/definitions/record_source"
+      "400":
+        description: Missing or wrong parameters
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "401":
+        description: Authentication required
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "403":
+        description: Not allowed
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "500":
+        description: |
+          Internal server error. Possible `error_code` attribute values:
+
+          * `internal_server_error`
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "503":
+        description: Under maintenance
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+    x-koha-authorization:
+      permissions:
+        parameters: manage_record_sources
+"/record_sources/{record_source_id}":
+  get:
+    x-mojo-to: RecordSources#get
+    operationId: getRecordSources
+    summary: Get a record source
+    tags:
+      - record_sources
+    parameters:
+      - $ref: "../swagger.yaml#/parameters/record_source_id_pp"
+    consumes:
+      - application/json
+    produces:
+      - application/json
+    responses:
+      "200":
+        description: A record source
+        schema:
+          $ref: "../swagger.yaml#/definitions/record_source"
+      "400":
+        description: Missing or wrong parameters
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "401":
+        description: Authentication required
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "403":
+        description: Not allowed
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "404":
+        description: Not found
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "500":
+        description: |
+          Internal server error. Possible `error_code` attribute values:
+
+          * `internal_server_error`
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "503":
+        description: Under maintenance
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+    x-koha-authorization:
+      permissions:
+        parameters: manage_record_sources
+  put:
+    x-mojo-to: RecordSources#update
+    operationId: updateRecordSources
+    summary: Update a record source
+    tags:
+      - record_sources
+    parameters:
+      - $ref: "../swagger.yaml#/parameters/record_source_id_pp"
+      - name: body
+        in: body
+        description: A JSON object containing informations about the new record source
+        required: true
+        schema:
+          $ref: "../swagger.yaml#/definitions/record_source"
+    consumes:
+      - application/json
+    produces:
+      - application/json
+    responses:
+      "200":
+        description: A record source
+        schema:
+          $ref: "../swagger.yaml#/definitions/record_source"
+      "400":
+        description: Missing or wrong parameters
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "401":
+        description: Authentication required
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "403":
+        description: Not allowed
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "404":
+        description: Not found
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "500":
+        description: |
+          Internal server error. Possible `error_code` attribute values:
+
+          * `internal_server_error`
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "503":
+        description: Under maintenance
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+    x-koha-authorization:
+      permissions:
+        parameters: manage_record_sources
+  delete:
+    x-mojo-to: RecordSources#delete
+    operationId: deleteRecordSources
+    summary: Delete a record source
+    tags:
+      - record_sources
+    parameters:
+      - $ref: "../swagger.yaml#/parameters/record_source_id_pp"
+    consumes:
+      - application/json
+    produces:
+      - application/json
+    responses:
+      "204":
+        description: Deleted
+      "400":
+        description: Missing or wrong parameters
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "401":
+        description: Authentication required
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "403":
+        description: Not allowed
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "404":
+        description: Not found
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "500":
+        description: |
+          Internal server error. Possible `error_code` attribute values:
+
+          * `internal_server_error`
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+      "503":
+        description: Under maintenance
+        schema:
+          $ref: "../swagger.yaml#/definitions/error"
+    x-koha-authorization:
+      permissions:
+        parameters: manage_record_sources
diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml
index 227921d7957..02f82f72ba8 100644
--- a/api/v1/swagger/swagger.yaml
+++ b/api/v1/swagger/swagger.yaml
@@ -108,6 +108,8 @@ definitions:
     $ref: ./definitions/import_batch_profiles.yaml
   import_record_match:
     $ref: ./definitions/import_record_match.yaml
+  record_source:
+    $ref: ./definitions/record_source.yaml
   invoice:
     $ref: ./definitions/invoice.yaml
   item:
@@ -483,6 +485,10 @@ paths:
     $ref: ./paths/quotes.yaml#/~1quotes
   "/quotes/{quote_id}":
     $ref: "./paths/quotes.yaml#/~1quotes~1{quote_id}"
+  /record_sources:
+    $ref: ./paths/record_sources.yaml#/~1record_sources
+  "/record_sources/{record_source_id}":
+    $ref: ./paths/record_sources.yaml#/~1record_sources~1{record_source_id}
   /return_claims:
     $ref: ./paths/return_claims.yaml#/~1return_claims
   "/return_claims/{claim_id}":
@@ -713,6 +719,12 @@ parameters:
     name: import_record_id
     required: true
     type: integer
+  record_source_id_pp:
+    description: Internal record source identifier
+    in: path
+    name: record_source_id
+    required: true
+    type: integer
   item_id_pp:
     description: Internal item identifier
     in: path
@@ -1116,6 +1128,9 @@ tags:
   - description: "Manage item groups\n"
     name: item_groups
     x-displayName: Item groups
+  - description: "Manage record sources\n"
+    name: record_sources
+    x-displayName: Record source
   - description: "Manage items\n"
     name: items
     x-displayName: Items
diff --git a/t/db_dependent/api/v1/record_sources.t b/t/db_dependent/api/v1/record_sources.t
new file mode 100755
index 00000000000..f82264a714d
--- /dev/null
+++ b/t/db_dependent/api/v1/record_sources.t
@@ -0,0 +1,301 @@
+#!/usr/bin/env perl
+
+# Copyright 2023 Theke Solutions
+#
+# This file is part of Koha.
+#
+# Koha is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
+#
+# Koha is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Koha; if not, see <http://www.gnu.org/licenses>.
+
+use Modern::Perl;
+
+use Test::More tests => 5;
+use Test::Mojo;
+
+use t::lib::TestBuilder;
+use t::lib::Mocks;
+
+use Koha::RecordSources;
+use Koha::Database;
+
+my $schema  = Koha::Database->new->schema;
+my $builder = t::lib::TestBuilder->new;
+
+t::lib::Mocks::mock_preference( 'RESTBasicAuth', 1 );
+
+my $t = Test::Mojo->new('Koha::REST::V1');
+
+subtest 'list() tests' => sub {
+
+    plan tests => 12;
+
+    $schema->storage->txn_begin;
+
+    my $source = $builder->build_object( { class => 'Koha::RecordSources' } );
+    my $patron = $builder->build_object(
+        {
+            class => 'Koha::Patrons',
+            value => { flags => 3 }
+        }
+    );
+
+    for ( 1 .. 10 ) {
+        $builder->build_object( { class => 'Koha::RecordSources' } );
+    }
+
+    my $nonprivilegedpatron = $builder->build_object(
+        {
+            class => 'Koha::Patrons',
+            value => { flags => 0 }
+        }
+    );
+
+    my $password = 'thePassword123';
+
+    $nonprivilegedpatron->set_password( { password => $password, skip_validation => 1 } );
+    my $userid = $nonprivilegedpatron->userid;
+
+    $t->get_ok("//$userid:$password@/api/v1/record_sources")->status_is(403)
+        ->json_is( '/error' => 'Authorization failure. Missing required permission(s).' );
+
+    $patron->set_password( { password => $password, skip_validation => 1 } );
+    $userid = $patron->userid;
+
+    $t->get_ok("//$userid:$password@/api/v1/record_sources?_per_page=10")->status_is( 200, 'SWAGGER3.2.2' );
+
+    my $response_count = scalar @{ $t->tx->res->json };
+
+    is( $response_count, 10, 'The API returns 10 sources' );
+
+    my $id = $source->record_source_id;
+    $t->get_ok("//$userid:$password@/api/v1/record_sources?q={\"record_source_id\": $id}")->status_is(200)
+        ->json_is( '' => [ $source->to_api ], 'SWAGGER3.3.2' );
+
+    $source->delete;
+
+    $t->get_ok("//$userid:$password@/api/v1/record_sources?q={\"record_source_id\": $id}")->status_is(200)
+        ->json_is( '' => [] );
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'get() tests' => sub {
+
+    plan tests => 9;
+
+    $schema->storage->txn_begin;
+
+    my $source = $builder->build_object( { class => 'Koha::RecordSources' } );
+    my $patron = $builder->build_object(
+        {
+            class => 'Koha::Patrons',
+            value => { flags => 3 }
+        }
+    );
+
+    my $nonprivilegedpatron = $builder->build_object(
+        {
+            class => 'Koha::Patrons',
+            value => { flags => 0 }
+        }
+    );
+
+    my $password = 'thePassword123';
+
+    $nonprivilegedpatron->set_password( { password => $password, skip_validation => 1 } );
+    my $userid = $nonprivilegedpatron->userid;
+
+    my $id = $source->record_source_id;
+
+    $t->get_ok("//$userid:$password@/api/v1/record_sources/$id")->status_is(403)
+        ->json_is( '/error' => 'Authorization failure. Missing required permission(s).' );
+
+    $patron->set_password( { password => $password, skip_validation => 1 } );
+    $userid = $patron->userid;
+
+    $t->get_ok("//$userid:$password@/api/v1/record_sources/$id")->status_is( 200, 'SWAGGER3.2.2' )
+        ->json_is( '' => $source->to_api, 'SWAGGER3.3.2' );
+
+    $source->delete;
+
+    $t->get_ok("//$userid:$password@/api/v1/record_sources/$id")->status_is(404)
+        ->json_is( '/error' => 'Object not found' );
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'delete() tests' => sub {
+
+    plan tests => 6;
+
+    $schema->storage->txn_begin;
+
+    my $source = $builder->build_object( { class => 'Koha::RecordSources' } );
+    my $patron = $builder->build_object(
+        {
+            class => 'Koha::Patrons',
+            value => { flags => 3 }
+        }
+    );
+
+    my $nonprivilegedpatron = $builder->build_object(
+        {
+            class => 'Koha::Patrons',
+            value => { flags => 0 }
+        }
+    );
+
+    my $password = 'thePassword123';
+
+    $nonprivilegedpatron->set_password( { password => $password, skip_validation => 1 } );
+    my $userid = $nonprivilegedpatron->userid;
+
+    my $id = $source->record_source_id;
+
+    $t->delete_ok("//$userid:$password@/api/v1/record_sources/$id")->status_is(403)
+        ->json_is( '/error' => 'Authorization failure. Missing required permission(s).' );
+
+    $patron->set_password( { password => $password, skip_validation => 1 } );
+    $userid = $patron->userid;
+
+    $t->delete_ok("//$userid:$password@/api/v1/record_sources/$id")->status_is( 204, 'SWAGGER3.2.2' );
+
+    my $deleted_source = Koha::RecordSources->search( { record_source_id => $id } );
+
+    is( $deleted_source->count, 0, 'No record source found' );
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'add() tests' => sub {
+
+    plan tests => 8;
+
+    $schema->storage->txn_begin;
+
+    my $patron = $builder->build_object(
+        {
+            class => 'Koha::Patrons',
+            value => { flags => 3 }
+        }
+    );
+
+    my $nonprivilegedpatron = $builder->build_object(
+        {
+            class => 'Koha::Patrons',
+            value => { flags => 0 }
+        }
+    );
+
+    my $password = 'thePassword123';
+
+    $nonprivilegedpatron->set_password( { password => $password, skip_validation => 1 } );
+    my $userid    = $nonprivilegedpatron->userid;
+    my $patron_id = $nonprivilegedpatron->borrowernumber;
+
+    $t->post_ok( "//$userid:$password@/api/v1/record_sources" => json => { name => 'test1' } )->status_is(403)
+        ->json_is( '/error' => 'Authorization failure. Missing required permission(s).' );
+
+    $patron->set_password( { password => $password, skip_validation => 1 } );
+    $userid = $patron->userid;
+
+    my $source_id =
+        $t->post_ok( "//$userid:$password@/api/v1/record_sources" => json => { name => 'test1' } )
+        ->status_is( 201, 'SWAGGER3.2.2' )->json_is( '/name', 'test1' )->json_is( '/can_be_edited', 0 )
+        ->tx->res->json->{record_source_id};
+
+    my $created_source = Koha::RecordSources->find($source_id);
+
+    is( $created_source->name, 'test1', 'Record source found' );
+
+    $schema->storage->txn_rollback;
+};
+
+subtest 'update() tests' => sub {
+
+    plan tests => 15;
+
+    $schema->storage->txn_begin;
+
+    my $librarian = $builder->build_object(
+        {
+            class => 'Koha::Patrons',
+            value => { flags => 2**3 }    # parameters flag = 2
+        }
+    );
+    my $password = 'thePassword123';
+    $librarian->set_password( { password => $password, skip_validation => 1 } );
+    my $userid = $librarian->userid;
+
+    my $patron = $builder->build_object(
+        {
+            class => 'Koha::Patrons',
+            value => { flags => 0 }
+        }
+    );
+
+    $patron->set_password( { password => $password, skip_validation => 1 } );
+    my $unauth_userid = $patron->userid;
+
+    my $source    = Koha::RecordSource->new( { name => 'old_name' } )->store;
+    my $source_id = $source->id;
+
+    # Unauthorized attempt to update
+    $t->put_ok( "//$unauth_userid:$password@/api/v1/record_sources/$source_id" => json =>
+            { name => 'New unauthorized name change' } )->status_is(403);
+
+    # Attempt partial update on a PUT
+    my $source_with_missing_field = {};
+
+    $t->put_ok( "//$userid:$password@/api/v1/record_sources/$source_id" => json => $source_with_missing_field )
+        ->status_is(400)->json_is( "/errors" => [ { message => "Missing property.", path => "/body/name" } ] );
+
+    # Full object update on PUT
+    my $source_with_updated_field = {
+        name => "new_name",
+    };
+
+    $t->put_ok( "//$userid:$password@/api/v1/record_sources/$source_id" => json => $source_with_updated_field )
+        ->status_is(200)->json_is( '/name' => $source_with_updated_field->{name} );
+
+    # Authorized attempt to write invalid data
+    my $source_with_invalid_field = {
+        name   => "blah",
+        potato => "yeah",
+    };
+
+    $t->put_ok( "//$userid:$password@/api/v1/record_sources/$source_id" => json => $source_with_invalid_field )
+        ->status_is(400)->json_is(
+        "/errors" => [
+            {
+                message => "Properties not allowed: potato.",
+                path    => "/body"
+            }
+        ]
+        );
+
+    my $source_to_delete = $builder->build_object( { class => 'Koha::RecordSources' } );
+    my $non_existent_id  = $source_to_delete->id;
+    $source_to_delete->delete;
+
+    $t->put_ok( "//$userid:$password@/api/v1/record_sources/$non_existent_id" => json => $source_with_updated_field )
+        ->status_is(404);
+
+    # Wrong method (POST)
+    $source_with_updated_field->{record_source_id} = 2;
+
+    $t->post_ok( "//$userid:$password@/api/v1/record_sources/$source_id" => json => $source_with_updated_field )
+        ->status_is(404);
+
+    $schema->storage->txn_rollback;
+};
-- 
2.43.0