Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
487fd05
feat: create AssignSponsorToProject class
HMS-Victory Jun 18, 2026
bb1bb5a
feat: link AssignSponsorToProject to justServeCommand
HMS-Victory Jun 18, 2026
bb97bcb
feat(wip): add tests for AssignSponsorToProject
HMS-Victory Jun 18, 2026
610a1b2
docs: add annotation for test failing from backend
HMS-Victory Jun 18, 2026
7e33374
fix: correct AssignSponsorToProject command name
HMS-Victory Jun 18, 2026
a781ac0
feat: link new AssignSponsorToProject command to command base
HMS-Victory Jun 18, 2026
4bd156d
feat: add support to find user by id
HMS-Victory Jun 18, 2026
37db736
feat: add manual checks to ensure only valid projects, or ids send ap…
HMS-Victory Jun 18, 2026
4c652a4
test: add basic testing for findUserById
HMS-Victory Jun 19, 2026
b835ed8
fix: repair rests for assignSponsorProject
HMS-Victory Jun 19, 2026
dc27448
refactor: place checks at the beginning of try catch for easier reada…
HMS-Victory Jun 19, 2026
6e7c295
fix: remove unnecessary semicolon
HMS-Victory Jun 19, 2026
4367438
Revert "feat: link AssignSponsorToProject to justServeCommand"
HMS-Victory Jun 19, 2026
138362d
Update cli/src/main/java/org/justserve/command/AssignSponsorToProject…
HMS-Victory Jun 22, 2026
689b0b6
Apply suggestions from code review - implement dynamic test title
HMS-Victory Jun 22, 2026
4f57618
fix: change exception to specifically refer to invalid input
HMS-Victory Jun 22, 2026
2ae36e3
fix: (wip) remove get api/v1/users/uid/limited
Jonathan-Zollinger Jun 24, 2026
39da3da
test: (wip) add test for slim search
Jonathan-Zollinger Jun 24, 2026
f34c0d5
refactor(wip): adjust assignSponsorToProject command to use a differe…
HMS-Victory Jun 30, 2026
e513719
fix: make the sponsor ID available in the wider scope
HMS-Victory Jul 2, 2026
7dbd214
refactor: clean up null checks to avoid using exceptions aside from f…
HMS-Victory Jul 2, 2026
6948033
fix: assign sponsorId after ensuring a user exists
HMS-Victory Jul 2, 2026
ce0a432
docs: add sponsor email in addition to the sponsor id in all of the r…
HMS-Victory Jul 2, 2026
5ec94e8
docs(fix): simplify into only one title to maintain.
HMS-Victory Jul 2, 2026
e5ddd8c
feat: if a createUser fails find and return a user instead
HMS-Victory Jul 2, 2026
7462e68
test: restore basic testing for apiV1UsersUserIdLimitedGet
HMS-Victory Jul 2, 2026
be502bd
fix: AssignSponsorToProjectSpec sending in id parameter instead of email
HMS-Victory Jul 7, 2026
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
2 changes: 1 addition & 1 deletion cli/src/main/java/org/justserve/JustServeCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import picocli.CommandLine.ParameterException;
import picocli.jansi.graalvm.AnsiConsole;

@Command(subcommands = {GetTempPassword.class, MakeOrgAdmin.class, UnReassignProjects.class, AssignOrgToProject.class},
@Command(subcommands = {GetTempPassword.class, MakeOrgAdmin.class, UnReassignProjects.class, AssignOrgToProject.class, AssignSponsorToProject.class},
mixinStandardHelpOptions = true,
name = "justserve", versionProvider = JustServeVersionProvider.class,
description = "justserve-cli is a terminal tool to help specialists and admin using JustServe")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package org.justserve.command;

import io.micronaut.http.client.exceptions.HttpClientResponseException;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.justserve.client.ProjectClient;
import org.justserve.client.UserClient;
import org.justserve.model.*;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import org.justserve.client.GraphQLClient;

import java.util.UUID;

import static org.justserve.util.JustServePrinter.printError;
import static org.justserve.util.JustServePrinter.printNormal;


@Slf4j
@Command(name = "assignSponsorToProject", description = "Assigns an sponsor to a project", mixinStandardHelpOptions = true)
public class AssignSponsorToProject extends BaseCommand implements Runnable {

@Option(names = {"--project", "-p"}, description = "the project ID", required = true)
private UUID projectId;

@Option(names = {"--user", "-u"}, description = "the user ID", required = true)
private String sponsorEmail;

@Inject
Provider<GraphQLClient> graphQLClientProvider;

@Inject
Provider<UserClient> userClientProvider;

@Inject
Provider<ProjectClient> projectClientProvider;

@Override
public void run() {
if (isTokenInvalid()) {
return;
}

GraphQLClient client = graphQLClientProvider.get();
UserClient userClient = userClientProvider.get();
ProjectClient projectClient = projectClientProvider.get();

// ensure the project id, and the sponsor id exist, since the backend does not throw an exception if they do not
UUID sponsorId;
try{
@Valid UserSlimSearchResults userResults=userClient.userSearchSlim(new UserSearchRequest(sponsorEmail, 1, 1)).block();
@Valid Project project = projectClient.getProject(projectId, " ", new GetProjectRequest()).block();

if (userResults == null || userResults.getUsers() == null || userResults.getUsers().isEmpty()) {
printError("Could not find sponsor with email %s", sponsorEmail);
return;
}else if (project == null) {
printError("Could not find project %s", projectId);
return;
}
sponsorId=userResults.getUsers().getFirst().getId();
}catch(HttpClientResponseException e){
printError("User API returned null for sponsorId = %s projectId = %s", sponsorEmail, projectId);
log.atError().setCause(e).log("Error response from API: {}", e.getMessage());
return;
}



GraphQLCombinedMutationUpdateProjectAddProjectTagVariables tagVariables = new GraphQLCombinedMutationUpdateProjectAddProjectTagVariables()
.setProjectId(projectId)
.setModify(new GraphQLCombinedMutationUpdateProjectAddProjectTagVariablesModify()
.setSponsorUserId(sponsorId));

try {
log.atTrace().log("Assigning sponsor ({}, {}) to project {}", sponsorId, sponsorEmail, projectId);

client.combinedMutationUpdateProjectAddProjectTag(tagVariables);

printNormal("Successfully assigned sponsor (%s, %s) to project %s", sponsorId, sponsorEmail, projectId);
log.atTrace().log("Assigning sponsor ({}, {}) to project {}", sponsorId, sponsorEmail, projectId);
} catch (HttpClientResponseException e) {
printError("Failed to assign sponsor (%s, %s) to project %s. %n%t Error Code %s: Error Message: %s",
sponsorId, sponsorEmail, projectId, e.getStatus().getCode(), e.getMessage());
log.atError().setCause(e).log("Error response from API: {}", e.getResponse().body());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package org.justserve.cli.command

import io.micronaut.test.extensions.spock.annotation.MicronautTest
import org.justserve.TestUser
import org.justserve.model.ProjectCard
import spock.lang.Execution
import spock.lang.Unroll

Comment thread
HMS-Victory marked this conversation as resolved.
import static org.spockframework.runtime.model.parallel.ExecutionMode.SAME_THREAD

@Execution(SAME_THREAD)
@MicronautTest
class AssignSponsorToProjectSpec extends BaseCommandSpec {

def "can assign a sponsor to a project without errors"() {
given:
def newReadOnlyUser = new TestUser(faker)
newReadOnlyUser.id = createUser().id
ProjectCard project = searchResults.first()

def args = ["assignSponsorToProject", "-p", project.getId().toString(), "-u", newReadOnlyUser.id]

when:
def (outputStream, errorStream) = executeCommand(ctx, args as String[])

then:
errorStream.matches(blankRegex)
outputStream.contains("Successfully assigned sponsor ${newReadOnlyUser.email} to project ${project.getId()}")
}

// things we need to test for, include when either the sponsor or the project do not exist

@Unroll
def "fails gracefully when #title"(){
given:
def readOnlyUser = new TestUser(faker)
readOnlyUser.id = createUser().id
def args = ["assignSponsorToProject", "-p", projectId, "-u", sponsorEmail]

when:
def (outputStream, errorStream) = executeCommand(ctx, args as String[])

then:
outputStream.matches(blankRegex)
errorStream.contains("Failed to assign sponsor ${sponsorEmail} to project ${projectId}. ${errorMessage}")

where:
title | projectId | sponsorId | errorMessage
"project does not exist" | UUID.randomUUID() | readOnlyUser.id | "(404: Client 'justserve': Not Found)"
"sponsor does not exist" | searchResults.first().getId() | UUID.randomUUID() | "(400: User Not Found)"
"project and sponsor do not exist" | UUID.randomUUID() | UUID.randomUUID() | "(400: User Not Found)"
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class UnReassignProjectsSpec extends BaseCommandSpec {
def setupSpec() {
testProjects = getProjectsByLocation(faker.location().toString())
def newReadOnlyUser = new TestUser(faker)
newReadOnlyUser.uuid = createUser().id
newReadOnlyUser.id = createUser().id
testEmails = new HashMap<>()
testEmails.put("forwarded-reassignment-email", [TestEmailGenerator.generateMockValidEmlContent(testProjects, readOnlyUser), readOnlyUser])
testEmails.put("automated-reassignment-email", [TestEmailGenerator.generateMockValidEmlContent(testProjects, newReadOnlyUser), newReadOnlyUser])
Expand All @@ -41,7 +41,7 @@ class UnReassignProjectsSpec extends BaseCommandSpec {
}
tempEmlFile = File.createTempFile(title, ".eml")
tempEmlFile.write(fileContent)
def args = ["unReassignProjects", "-u", user.uuid.toString(), "-f", tempEmlFile.absolutePath]
def args = ["unReassignProjects", "-u", user.id.toString(), "-f", tempEmlFile.absolutePath]
def projectCount = EmailParser.getProjects(fileContent).values().flatten().size()

when:
Expand All @@ -51,7 +51,7 @@ class UnReassignProjectsSpec extends BaseCommandSpec {
errorStream.matches(blankRegex)
testProjects.each { project -> outputStream.contains(project.id.toString())
}
outputStream.contains("Successfully reassigned ${projectCount} projects to user ${user.uuid}")
outputStream.contains("Successfully reassigned ${projectCount} projects to user ${user.id}")

cleanup:
try {
Expand All @@ -69,7 +69,7 @@ class UnReassignProjectsSpec extends BaseCommandSpec {
def emailContent = TestEmailGenerator.generateMockValidEmlContent([invalidProject], readOnlyUser)
tempEmlFile = File.createTempFile("invalid-project", ".eml")
tempEmlFile.write(emailContent)
def args = ["unReassignProjects", "-u", readOnlyUser.uuid.toString(), "-f", tempEmlFile.absolutePath]
def args = ["unReassignProjects", "-u", readOnlyUser.id.toString(), "-f", tempEmlFile.absolutePath]

when:
def (outputStream, errorStream) = executeCommand(ctx, args as String[])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ class TestEmailGenerator {
sb.append("Content-Type: text/html; charset=\"UTF-8\"\n")
sb.append("Content-Transfer-Encoding: quoted-printable\n\n")

String htmlTemplateStart = """<div dir="ltr"><div class="gmail_default" style="font-family:arial,helvetica,sans-serif;font-size:small">CCJSS ${faker.name().fullName()} reassigned ${projects.size()} projects to their lead JSS account, <a href="https://www.justserve.org/admin/users?tab=detail&amp;userId=${recipient.uuid}">${recipientName}</a>.</div><br><div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">---------- Forwarded message ---------<br>From: <strong class="gmail_sendername" dir="auto">JustServe.org</strong> <span dir="auto">&lt;<a href="mailto:noreply-js@mail.justserve.org" target="_blank">noreply-js@mail.justserve.org</a>&gt;</span><br>Date: Thu, Mar 12, 2026 at 1:43&#8239;PM<br>Subject: Project Reassignment<br>To: &lt;<a href="mailto:${recipientEmail}" target="_blank">${recipientEmail}</a>&gt;<br></div><br><br><div><u></u>
String htmlTemplateStart = """<div dir="ltr"><div class="gmail_default" style="font-family:arial,helvetica,sans-serif;font-size:small">CCJSS ${faker.name().fullName()} reassigned ${projects.size()} projects to their lead JSS account, <a href="https://www.justserve.org/admin/users?tab=detail&amp;userId=${recipient.id}">${recipientName}</a>.</div><br><div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">---------- Forwarded message ---------<br>From: <strong class="gmail_sendername" dir="auto">JustServe.org</strong> <span dir="auto">&lt;<a href="mailto:noreply-js@mail.justserve.org" target="_blank">noreply-js@mail.justserve.org</a>&gt;</span><br>Date: Thu, Mar 12, 2026 at 1:43&#8239;PM<br>Subject: Project Reassignment<br>To: &lt;<a href="mailto:${recipientEmail}" target="_blank">${recipientEmail}</a>&gt;<br></div><br><br><div><u></u>
<div style="margin:0px">
<table aria-describedby="table" style="background-color:#e5e3e3;border-collapse:collapse;width:100%" role="presentation">
<tbody>
Expand Down
46 changes: 46 additions & 0 deletions core/src/main/resources/schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ImageUploadResponse'
/api/v1/users/{userId}/limited:
get:
tags:
- User
parameters:
- name: userId
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: OK
content:
text/plain:
schema:
$ref: "UserResultLimited"
application/json:
schema:
$ref: "UserResultLimited"
text/json:
schema:
$ref: "UserResultLimited"
/api/v1/languages:
get:
tags: [ Language ]
Expand Down Expand Up @@ -2089,6 +2113,28 @@ components:
items: { $ref: '#/components/schemas/ProjectBounded' }
nullable: true
additionalProperties: false
UserResultLimited:
type: object
properties:
firstName:
type: string
nullable: true
lastName:
type: string
nullable: true
name:
type: string
nullable: true
readOnly: true
email:
type: string
nullable: true
userId:
type: string
nullable: true
projects:
$ref: "#/components/schemas/ProjectSlimResponse"
additionalProperties: false
UserSearchRequest:
description: |
search query used in a few different endpoints
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ class GraphQLClientSpec extends JustServeSpec {
def vars = new GraphQLCombinedMutationUpdateProjectAddProjectTagVariables()
.setProjectId(project.getId())
.setModify(new GraphQLCombinedMutationUpdateProjectAddProjectTagVariablesModify()
.setSponsorUserId(readOnlyUser.uuid)
.setSponsorUserId(readOnlyUser.id)
)

when:
Expand Down
14 changes: 12 additions & 2 deletions core/src/test/groovy/org/justserve/JustServeSpec.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.http.client.multipart.MultipartBody
import io.micronaut.retry.annotation.Retryable
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import jakarta.validation.Valid
import net.datafaker.Faker
import org.apache.commons.lang3.RandomStringUtils
import org.justserve.client.*
Expand Down Expand Up @@ -88,7 +89,7 @@ class JustServeSpec extends Specification {
readOnlyUser = new TestUser(new Faker(Locale.of("en-us")))
projectClient = ctx.getBean(ProjectClient)
String customRandomEmail = RandomStringUtils.insecure().nextAlphanumeric(20) + "@fake.com"
readOnlyUser.uuid = createUserFromFaker(noAuthUserClient, readOnlyUser, customRandomEmail).getId()
readOnlyUser.id = createUserFromFaker(noAuthUserClient, readOnlyUser, customRandomEmail).getId()
readOnlyUser.email = customRandomEmail
searchResults = getProjectsByLocation(faker.location().toString())
}
Expand Down Expand Up @@ -119,6 +120,8 @@ class JustServeSpec extends Specification {
return response
}

// Initially tries to create a new user, but if it fails it creates a new one instead

@Retryable
private static CreateUser200Response createUserFromFaker(UserClient client, TestUser user, String uniqueEmailInput = null) {
String email = uniqueEmailInput ?: RandomStringUtils.insecure().nextAlphanumeric(20) + "@fake.com"
Expand All @@ -133,7 +136,14 @@ class JustServeSpec extends Specification {
.addPart("country", user.country)
.addPart("termsChecked", "true")
.build()
client.createUser(requestBody).block()

try{
client.createUser(requestBody).block()
}catch(HttpClientResponseException e){
@Valid UserSlimSearchResult slimUser=client.userSearchSlim(new UserSearchRequest("James", 0, 1)).block().getUsers().getFirst()
@Valid UserResultLimited fullUser = client.apiV1UsersUserIdLimitedGet(slimUser.getId()).block();
return new CreateUser200Response().setEmail(fullUser.getEmail()).setId(fullUser.getUserId() as UUID)
}
}

List<ProjectCard> getProjectsByLocation(String location) {
Expand Down
6 changes: 4 additions & 2 deletions core/src/test/groovy/org/justserve/ProjectClientSpec.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.justserve

import io.micronaut.test.extensions.spock.annotation.MicronautTest
import org.justserve.model.*
import spock.lang.PendingFeature

import static org.justserve.model.DistanceType.MILES

Expand Down Expand Up @@ -33,7 +34,7 @@ class ProjectClientSpec extends JustServeSpec {
String locale = new Random().nextBoolean() ? " " : "en-US"
UUID currentOwner = projectClient.getProject((project as ProjectCard).getId(), locale, projectRequest).block()
.getProjectOwnerUserId()
ReassignProjectRequest reassignProjectRequest = new ReassignProjectRequest(readOnlyUser.uuid, currentOwner)
ReassignProjectRequest reassignProjectRequest = new ReassignProjectRequest(readOnlyUser.id, currentOwner)

when:
projectClient.reassignProject((project as ProjectCard).getId(), reassignProjectRequest).block()
Expand All @@ -42,7 +43,7 @@ class ProjectClientSpec extends JustServeSpec {
noExceptionThrown()
verifyAll {
projectClient.getProject((project as ProjectCard).getId(), "en-US", projectRequest).block()
.getProjectOwnerUserId() == readOnlyUser.uuid
.getProjectOwnerUserId() == readOnlyUser.id
}

where:
Expand All @@ -67,6 +68,7 @@ class ProjectClientSpec extends JustServeSpec {
}
}

@PendingFeature(reason = "bug in justserve api where reassignment makes no change")
Comment thread
HMS-Victory marked this conversation as resolved.
void "can assign an organization to a project"() {
given:
def orgSearchResponse = authOrgClient.searchByLocation(createSearchRequestForElkGrove()).block()
Expand Down
24 changes: 19 additions & 5 deletions core/src/test/groovy/org/justserve/TestUser.groovy
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
package org.justserve


import net.datafaker.Faker
import net.datafaker.providers.base.Address
import org.justserve.model.CreateUser200Response
import org.justserve.model.UserSlimSearchResult

class TestUser {

public String email, firstName, lastName, password, zipcode, countryCode, country, locale
public UUID uuid = null
public String email, firstName, lastName, password, zipcode, countryCode, country, locale, username
public UUID id = null

/**
* constructor that generates fake data for the model's properties.
*
* @param faker seed for a faker, say a specific locale.
* @param faker seed for a faker, say a specific locale.
*/
TestUser(Faker faker) {
this.email = faker.internet().emailAddress()
Expand All @@ -23,6 +24,19 @@ class TestUser {
this.countryCode = address.countryCode()
this.country = address.country()
this.locale = faker.locality().localeString()
this.password = faker.credentials().password(8,100,true,true,true)
this.password = faker.credentials().password(8, 100, true, true, true)
}

/**
* Creates a test user from a successful user creation response
*
* @param
*/
TestUser(UserSlimSearchResult searchResponse, CreateUser200Response createResponse) {
this.id = searchResponse.id
this.email = createResponse.email
this.firstName = searchResponse.firstName
this.lastName = searchResponse.lastName
this.username = searchResponse.userName
}
}
Loading