Skip to content

OBPIH-7387 record stock and create product APIs and tests#5455

Merged
ewaterman merged 3 commits intodevelopfrom
ft/OBPIH-7387-recordstock-product-apis
Sep 5, 2025
Merged

OBPIH-7387 record stock and create product APIs and tests#5455
ewaterman merged 3 commits intodevelopfrom
ft/OBPIH-7387-recordstock-product-apis

Conversation

@ewaterman
Copy link
Member

@ewaterman ewaterman commented Aug 21, 2025

✨ Description of Change

Link to GitHub issue or Jira ticket: https://pihemr.atlassian.net/browse/OBPIH-7387

Description: This is a big change but 95% of it is in tests. I've commented on the important pieces for review and I expect that we'll go over everything together in tech huddle.

Essentially this the first practical attempt at working towards: https://pihemr.atlassian.net/wiki/spaces/OB/pages/3709894657/OBPIH-7395+Making+the+App+Testable

As a TLDR of that link, the long term goal is to do the following operations on each of our features:

  1. If some essential functionality doesn’t expose API endpoints (because it's still using the old GSP approach), write a simple XApiController for that functionality that does exactly what the old XController does. Don't refactor any of the logic yet.
  2. Write API tests on the new endpoints
  3. Once the API tests pass, refactor the service, splitting it up into smaller classes with the goal of making it more unit testable
  4. Write unit tests on the new classes that were created

This PR is steps 1 and 2 for record stock and product create.

The reason the PR is so big is because in addition to simply writing tests for these APIs I wrapped them in a bunch of convenience functionality. I did this because these two APIs are fundamental for our tests and so having that convenience functionality allows other tests to use those APIs easily to set the database to a certain state ahead of test execution.

@ewaterman ewaterman self-assigned this Aug 21, 2025
@github-actions github-actions bot added type: feature A new piece of functionality for the app domain: backend Changes or discussions relating to the backend server flag: config change Hilights a pull request that contains a change to the app config labels Aug 21, 2025
# would be ambiguous and would lead to Date conversion weirdness. If you need a date only, use LocalDate.
- "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"
- "yyyy-MM-dd'T'HH:mm:ss.SSSXX"
- "yyyy-MM-dd'T'HH:mm:ss.SSSX"
Copy link
Member Author

Choose a reason for hiding this comment

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

This allows us to pass in dates with milliseconds. In tests, to convert a date to a json payload we do: date?.toInstant()?.toString() which includes the milliseconds. Without this change, it would fail to marshall dates on the backend to a Date field in a command object.

This seemed like usefull behaviour to support in general (it's a part of the ISO format), so I included it.

]
}

Map toJsonFull() {
Copy link
Member Author

Choose a reason for hiding this comment

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

This was needed for tests since we want to be able to make asserts on the full object. It's only used in the new create product API, but we may want to consider using it more broadly since it's probably generally helpful to return all fields of the object.

I think we should have a separate discussion about how we want to jsonify our objects going forward. It'd be nice to have a standard that we can follow.

My rule was: include all fields and for all relationships, include the id so that the caller can look them up if they need to

Copy link
Collaborator

Choose a reason for hiding this comment

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

I agree the id should be enough and this is probably what Grails does out of the box if you were to return the whole domain object (not the JSONed one, but the domain itself).
Nitpicky: maybe toFullJson() sounds better? In the same way like we have toBaseJson() for the Location probably.
Btw. since Grails returns only id for the associations, we might say that this toJson is not needed, but I am a big fan of DTOs and not returning domains to the representation layer, so I love the idea of either having "toJsons" for everything or DTO classes, and maybe consider something like MapStruct for such parsing.

Copy link
Member Author

Choose a reason for hiding this comment

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

and this is probably what Grails does out of the box if you were to return the whole domain object

You mean like if I did this:

[
    productType: productType
}

Wouldn't that pull the whole productType entity? Or maybe it'd be the equivalent of productType.toString() I can't remember.

I like toFullJson() I'll use that.

I am a big fan of DTOs and not returning domains to the representation layer, so I love the idea of either having "toJsons" for everything or DTO classes, and maybe consider something like MapStruct for such parsing

Yeah I think this is something we should look into further. In my previous job we had DTOs for every API and we'd either add the mapping directly to the DTO or use XConverter classes that would map a domain to a DTO for responses and a DTO to a domain for requests. We never used MapStruct but it seems like it could be a good solution.

}

// TODO: This was copied almost directly from ProductController. Refactor both methods to shift this logic into
// ProductService (and ideally create some helper classes for the service to use to split up the logic).
Copy link
Member Author

Choose a reason for hiding this comment

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

This comment is about what I talk about here: https://pihemr.atlassian.net/wiki/spaces/OB/pages/3709894657/OBPIH-7395+Making+the+App+Testable#Expose-API-endpoints-for-public-service-methods

Essentially, in order for us to test the app, we need to make it more testable. IMO that should be done in multiple phases:

  1. If some essential functionality doesn’t expose API endpoints (because it's still using the old GSP approach), write a simple XApiController for that functionality that does exactly what the old XController does. Don't refactor any of the logic yet.
  2. Write API tests on the new endpoints
  3. Once the API tests pass, refactor the service, splitting it up into smaller classes with the goal of making it more unit testable
  4. Write unit tests on the new classes that were created

Repeat the above for other features.

So this PR is step 1 and 2. A future PR could revisit this controller and service to refactor the flow, break it up into helper classes, and unit test those classes.

The idea is that since we'll already have the API tests, we can be somewhat confident that our refactoring hasn't broken the core flow.

Then much later when we want to refactor the page to use React, we already have the APIs ready to go.

Copy link
Collaborator

Choose a reason for hiding this comment

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

we have to be careful with that method though. it might not collect potential errors and exceptions in the same way like in the ProductController method.
The reason for that is that the ProductApiController should not be marked as @Transactional - we would want to handle the errors and commit the transaction in the service, and for this controller, we commit it at the end of the session, hence we might have some silent exceptions here.
Not something for you to refactor though, but wanted to point that out, that those methods might not act in the same way for some scenarios.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point. There's a lot of refactoring that needs to be done to this controller and we should keep an eye out for any strange behaviours relating to transactions and transaction rollbacks

// currently (filtering out any items with no quantity). This means we're allowed to simply query product
// availability since it contains up to date stock.
Date now = new Date()
if (at == null || at == now || at.after(now)) {
Copy link
Member Author

Choose a reason for hiding this comment

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

This was just a bug fix that I discovered during testing. If we do a record stock in the current second, the adjustment will be 1 second in the future, and was breaking this flow.

Copy link
Collaborator

Choose a reason for hiding this comment

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

are we sure it's a safe change? if we record stock in the current second we would like a baseline to be created in the current second, but the adjustment should be created one second later. I just want to make sure we don't break the reports with that change.

Copy link
Member Author

Choose a reason for hiding this comment

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

yes the adjustment will still be one second in the future. This change just stops it from erroring if we say "give me product availability at date x" where x is in the future. With this change, it'll return the current product availability.

Record stock is fetching availability at the given date + 2 seconds and since tests do record stock at now - 1 second (which results in the baseline being created at now -1 second and the adjustment being created now), it was trying to fetch availability at now + 1 which errored.


@TestComponent
@InheritConstructors
class RecordStockApi extends AuthenticatedApi {
Copy link
Member Author

Choose a reason for hiding this comment

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

This is an example of an XApi class that you'd write when adding a new API that you want the tests to be able to call.

These classes are simple. There should be one method per API, and all they do is set up the request params and make the request.

Copy link
Collaborator

Choose a reason for hiding this comment

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

what does it mean?

There should be one method per API

does it mean if I were to add e.g. a delete method I shouldn't add it here?

Copy link
Member Author

@ewaterman ewaterman Sep 3, 2025

Choose a reason for hiding this comment

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

You could add it here. I just mean that this class shouldn't have a bunch of helper methods associated with that delete method. That's what the XApiWrapper class is for. This class is for the API calls, nothing else

See ProductApi.groovy for an example. There are a bunch of methods in there but only one per API


@TestComponent
@InheritConstructors
class RecordStockApiWrapper extends ApiWrapper<RecordStockApi> {
Copy link
Member Author

Choose a reason for hiding this comment

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

This is an example of an XApiWrapper class. You don't need to make these if you don't want to, but they can be used to enhance API calls with some convenience behaviour such as:

  • allowing callers to pass objects as a request body (and then mapping them to json)
  • automatically asserting on the response code
  • binding response json to an object for callers to use

return new LocationTestBuilder().findOrBuildMainFacility()
}

private Product createMainProduct() {
Copy link
Member Author

Choose a reason for hiding this comment

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

This is a good example of how the wrapper and test builder convenience classes are used. We build a Product using the test builder, then pass the Product to the create product API via the wrapper, which converts the product to json, makes the request, asserts that a 200 code is returned, then converts the json response to a Product.

It significantly simplifies operations that tests will need to do frequently.

Copy link
Collaborator

@kchelstowski kchelstowski left a comment

Choose a reason for hiding this comment

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

I still have to spend some time myself to analyze in the details the tests' code itself, but since you are an expert in that, don't expect any requested changes in the tests code.
I must admit the tests look pretty smooth and easy to write and understand at first sight so thank you for that. I hope I will feel the same way when I write them myself 😆
Good job 💪

and... sorry for such a long delay with the code review.

action = [GET: "search"]
}

"/api/products/save"(parseRequest: true) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

given our convention, shouldn't it be /api/products that is reusable for all HTTP methods?
so in this case the POST would be responsible for save, so we could just add a method to 111th line.
Btw. parseRequest is not probably necessary anymore in Gralis 3.

Copy link
Member Author

Choose a reason for hiding this comment

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

I did it this way because the existing APIs are /product/save, /product/update... and I wanted to preserve the behaviour but now I realize there's no reason to keep that structure for the new APIs so yes you're right I'll do it that way

}

// TODO: This was copied almost directly from ProductController. Refactor both methods to shift this logic into
// ProductService (and ideally create some helper classes for the service to use to split up the logic).
Copy link
Collaborator

Choose a reason for hiding this comment

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

we have to be careful with that method though. it might not collect potential errors and exceptions in the same way like in the ProductController method.
The reason for that is that the ProductApiController should not be marked as @Transactional - we would want to handle the errors and commit the transaction in the service, and for this controller, we commit it at the end of the session, hence we might have some silent exceptions here.
Not something for you to refactor though, but wanted to point that out, that those methods might not act in the same way for some scenarios.

class RecordStockApiController {

def inventoryService
def productAvailabilityService
Copy link
Collaborator

Choose a reason for hiding this comment

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

let's use types here

inventoryService.saveRecordInventoryCommand(command, params)
if (command.hasErrors()) {
throw new ValidationException("Invalid record stock", command.errors)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

in the future we might consider refactoring the service method to validate the command while binding like we used to do recently in many places, so that we could swap the order of save and checking if the command has the errors.

Copy link
Member Author

Choose a reason for hiding this comment

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

Agreed. This is something we can look at when refactoring in the future

throw new ValidationException("Invalid record stock", command.errors)
}

// Product availability was not refreshed during the record stock so we have to do it manually now.
Copy link
Collaborator

Choose a reason for hiding this comment

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

are you sure it did not refresh? it should refresh after each transaction save via event. Maybe you have run into an issue that you were editing the code and the events stopped being triggered?

Copy link
Member Author

Choose a reason for hiding this comment

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

I copied this from the old InventoryItemController. I assume this is done because the refresh is disabled in InventoryService: https://github.com/openboxes/openboxes/blob/develop/grails-app/services/org/pih/warehouse/inventory/InventoryService.groovy#L1477-L1480

I think this is something that we should address when refactoring record stock, which is why I left the behaviour as is for now.

]
}

Map toJsonFull() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I agree the id should be enough and this is probably what Grails does out of the box if you were to return the whole domain object (not the JSONed one, but the domain itself).
Nitpicky: maybe toFullJson() sounds better? In the same way like we have toBaseJson() for the Location probably.
Btw. since Grails returns only id for the associations, we might say that this toJson is not needed, but I am a big fan of DTOs and not returning domains to the representation layer, so I love the idea of either having "toJsons" for everything or DTO classes, and maybe consider something like MapStruct for such parsing.

// currently (filtering out any items with no quantity). This means we're allowed to simply query product
// availability since it contains up to date stock.
Date now = new Date()
if (at == null || at == now || at.after(now)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

are we sure it's a safe change? if we record stock in the current second we would like a baseline to be created in the current second, but the adjustment should be created one second later. I just want to make sure we don't break the reports with that change.


@TestComponent
@InheritConstructors
class RecordStockApi extends AuthenticatedApi {
Copy link
Collaborator

Choose a reason for hiding this comment

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

what does it mean?

There should be one method per API

does it mean if I were to add e.g. a delete method I shouldn't add it here?

.setBody(body)
.build()

return request(requestSpec, responseSpec, Method.POST, "/facilities/{facilityId}/inventory/record-stock/save")
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it would be good to build the url factory in the same way like on the frontend, to be able to reuse the URLs or parts of them

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't really like just having a huge urls file with all of them in there but you're probably right that at least it's better than hardcoding them everywhere. Maybe there's a smarter way to do it though such as breaking it up by feature.

Comment on lines +1061 to +1065
"/api/facilities/$facility/inventory/record-stock/save" {
controller = "recordStockApi"
action = [POST: "saveRecordStock"]
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it should be in something like "inventoryApiController", record stock sounds like a feature from a UI perspective, from the backend perspective, I would say it's related to managing inventories

Copy link
Member Author

Choose a reason for hiding this comment

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

it's an interesting thought. I don't necessarily think record stock is a UI feature but I see your point.

I wanted to avoid putting everything into a general inventoryApiController because the current inventoryController is 1500 lines and I'm scared we'd eventually end up with the same. Classes should have a clear purpose and IMO we should strive to draw a clearer line between feature boundaries.

If we want to treat "inventory management" as the base feature, I think we'd need to break it down further into sub-features.

So if I'm imagining a folder structure, it could be:

- inventory
    - cycleCount
    - recordStock
    - inventoryImport

which is why I went with /inventory/record-stock in the uri and why I created a recordStockApi as opposed to an inventoryApi. CycleCount has cycleCountApiController and inventory import has its own classes so why not the same for record stock?

Currently record stock is built in inventoryService, which is 3300+ lines of code. It's pretty hard to navigate a file that big and there's no separation of features. It's all in the same place. I think we should be trying to find ways that we can drive bigger lines between features and one piece of that is splitting out into more feature-specific controllers.

But we can discuss. I'm open to other ideas as well.

Copy link
Member Author

Choose a reason for hiding this comment

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

@alannadolny I'm going to merge this as is so that I can start using these test helpers, but this API is only used in tests right now so lets continue the discussion and we can easily refactor this if needed.

@ewaterman ewaterman merged commit 477b1a6 into develop Sep 5, 2025
3 of 5 checks passed
@ewaterman ewaterman deleted the ft/OBPIH-7387-recordstock-product-apis branch September 5, 2025 19:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain: backend Changes or discussions relating to the backend server flag: config change Hilights a pull request that contains a change to the app config type: feature A new piece of functionality for the app

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants