From e34a9529b4a765a221d682820383eafa53ced18c Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Tue, 17 Mar 2026 12:33:03 -0600 Subject: [PATCH 01/12] test(wip): remove need to provide own .eml files --- .../org/justserve/util/EmailParserSpec.groovy | 20 ++--- .../justserve/util/TestEmailGenerator.groovy | 90 ++++++++++++++++--- 2 files changed, 88 insertions(+), 22 deletions(-) diff --git a/cli/src/test/groovy/org/justserve/util/EmailParserSpec.groovy b/cli/src/test/groovy/org/justserve/util/EmailParserSpec.groovy index dfedd4b..fd20821 100644 --- a/cli/src/test/groovy/org/justserve/util/EmailParserSpec.groovy +++ b/cli/src/test/groovy/org/justserve/util/EmailParserSpec.groovy @@ -26,16 +26,16 @@ class EmailParserSpec extends Specification { def setupSpec() { testEmails = new HashMap<>() - Stream.of("sara-anderson-email.eml", "test-with-automated-email.eml", "test-without-automated-email.eml").forEach { file -> - def resource = resourceResolver.getResourceAsStream("classpath:$file") - resource.ifPresent { stream -> - try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { - testEmails.put(file.replace(".eml", ""), reader.lines().collect(Collectors.joining(System.lineSeparator()))) - } catch (IOException e) { - throw new RuntimeException("Failed to read test file: $file", e) - } - } - } + + Faker faker = new Faker() + TestUser recipient = new TestUser(faker) + List mockProjects = [ + new ProjectCard(id: UUID.randomUUID(), title: faker.book().title()), + new ProjectCard(id: UUID.randomUUID(), title: faker.book().title()) + ] + + testEmails.put("test-with-automated-email", TestEmailGenerator.generateMockValidEmlContent(mockProjects, recipient)) + testEmails.put("test-without-automated-email", TestEmailGenerator.generateInvalidMockEmlContent()) testTrackingUrls = new HashMap<>() def yamlResource = resourceResolver.getResourceAsStream("classpath:projects.yaml") diff --git a/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy b/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy index dfa67a0..bf1068a 100644 --- a/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy +++ b/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy @@ -6,20 +6,59 @@ import org.justserve.model.ProjectCard class TestEmailGenerator { - static String generateMockEmlContent(List projects, TestUser recipient) { + static String generateMockValidEmlContent(List projects, TestUser recipient) { StringBuilder sb = new StringBuilder() - sb.append("From: JustServe \n") - sb.append("To: " + recipient.firstName + " " + recipient.lastName + " <" + recipient.email + ">\n") - sb.append("Subject: Project Reassignment\n") - sb.append("Content-Type: text/html; charset=\"UTF-8\"\n") - sb.append("Content-Transfer-Encoding: 8bit\n\n") - + Faker faker = new Faker() + String recipientName = recipient.firstName + " " + recipient.lastName String recipientEmail = recipient.email - Faker faker = new Faker() String senderName = faker.name().fullName() + String senderEmail = faker.internet().emailAddress() + String boundary = "000000000000" + faker.internet().uuid().replace("-", "").substring(0, 16) + String phoneNumber = faker.phoneNumber().phoneNumber() + + sb.append("Return-Path: <").append(senderEmail).append(">\n") + sb.append("Date: ").append(faker.date().past(1, java.util.concurrent.TimeUnit.DAYS)).append("\n") + sb.append("From: ").append(senderName).append(" <").append(senderEmail).append(">\n") + sb.append("To: JustServeSupport \n") + sb.append("Subject: Fwd: Project Reassignment\n") + sb.append("Mime-Version: 1.0\n") + sb.append("Content-Type: multipart/alternative; boundary=").append(boundary).append("\n\n") + + // Plain text part + sb.append("--").append(boundary).append("\n") + sb.append("Content-Type: text/plain; charset=UTF-8\n") + sb.append("Content-Transfer-Encoding: quoted-printable\n\n") + + sb.append("Hi Jonathan,=\n\n") + sb.append("Here you go. Thx for all of your help.=\n\n") + sb.append(senderName).append("=\n\n") + sb.append("---------- Forwarded message ---------\n") + sb.append("From: JustServe.org \n") + sb.append("Date: Tue, Dec 16, 2025 at 8:27 AM\n") + sb.append("Subject: Project Reassignment\n") + sb.append("To: <").append(senderEmail).append(">\n\n") + sb.append("Project Reassignment\n\n") + sb.append("The following projects have been reassigned from ").append(faker.name().fullName()).append(", to ") + .append(recipientName).append(", by ").append(senderName).append(":\n\n") + + projects.each { project -> + sb.append(" - ").append(project.title).append("\n") + sb.append(" \n") + } + + sb.append("\n").append(recipientName).append(" can now access these projects in their manage projects page for editing.\n\n") + sb.append("JustServe.org is provided as a service by The Church of Jesus Christ of Latter-day Saints.\n\n") + sb.append("© 2020 by Intellectual Reserve, Inc. All rights reserved.\n\n") + sb.append("50 East North Temple, Salt Lake City, Utah, 84150\n\n") + sb.append("This email was sent to: ").append(senderEmail).append("\n\n") - String htmlTemplateStart = """
Here you go.  Thx for your help.  

---------- Forwarded message ---------
From: JustServe.org <noreply-js@mail.justserve.org>
Date: Tue, Dec 16, 2025 at 8:27 AM
Subject: Project Reassignment
To: <${recipientEmail}>


+ // HTML part + sb.append("--").append(boundary).append("\n") + sb.append("Content-Type: text/html; charset=UTF-8\n") + sb.append("Content-Transfer-Encoding: quoted-printable\n\n") + + String htmlTemplateStart = """
Here you go.  Thx for your help.  

---------- Forwarded message ---------
From: JustServe.org <noreply-js@mail.justserve.org>
Date: Tue, Dec 16, 2025 at 8:27 AM
Subject: Project Reassignment
To: <${senderEmail}>


@@ -55,7 +94,7 @@ class TestEmailGenerator {

- The following projects have been reassigned from ${senderName}, to ${recipientName}, by ${senderName}: + The following projects have been reassigned from ${faker.name().fullName()}, to ${recipientName}, by ${senderName}:

-


--
${recipientName}
JustServe Assistant Area Director
Northern California 
925-699-1395  Cell and Text



+


--
${senderName}
JustServe Assistant Area Director
${faker.address().state()} 
${phoneNumber}  Cell and Text



""" sb.append(htmlTemplateStart) @@ -123,6 +162,33 @@ class TestEmailGenerator { } sb.append(htmlTemplateEnd) + + sb.append("\n--").append(boundary).append("--\n") + return sb.toString() + } + + static String generateInvalidMockEmlContent() { + StringBuilder sb = new StringBuilder() + Faker faker = new Faker() + + String senderName = faker.name().fullName() + String senderEmail = faker.internet().emailAddress() + + sb.append("Return-Path: <").append(senderEmail).append(">\n") + sb.append("Date: ").append(faker.date().past(1, java.util.concurrent.TimeUnit.DAYS)).append("\n") + sb.append("From: ").append(senderName).append(" <").append(senderEmail).append(">\n") + sb.append("To: JustServeSupport \n") + sb.append("Subject: Random user email without JS content\n") + sb.append("Mime-Version: 1.0\n") + sb.append("Content-Type: text/plain; charset=UTF-8\n") + sb.append("Content-Transfer-Encoding: quoted-printable\n\n") + + sb.append("Hi Jonathan,=\n\n") + sb.append("Can you help me reset my password? I forgot it.\n\n") + sb.append("Thanks,\n") + sb.append(senderName).append("\n") + return sb.toString() } } + From c50f6b6662c488593e2f8236157eb1f1c8e29a64 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Tue, 17 Mar 2026 13:30:19 -0600 Subject: [PATCH 02/12] test: email generators and parsing zendesk with proofpoint --- .../java/org/justserve/util/EmailParser.java | 14 ++- .../org/justserve/util/EmailParserSpec.groovy | 10 +- .../justserve/util/TestEmailGenerator.groovy | 93 ++++++++++++++++++- cli/src/test/resources/README.md | 13 --- 4 files changed, 109 insertions(+), 21 deletions(-) delete mode 100644 cli/src/test/resources/README.md diff --git a/cli/src/main/java/org/justserve/util/EmailParser.java b/cli/src/main/java/org/justserve/util/EmailParser.java index 4fb4e13..ddc533a 100644 --- a/cli/src/main/java/org/justserve/util/EmailParser.java +++ b/cli/src/main/java/org/justserve/util/EmailParser.java @@ -160,11 +160,19 @@ private static String getHtmlBody(Part part) throws MessagingException, IOExcept * not contain the 'www.justserve.org*2Fprojects*2F' string. */ static UUID getProjectIDFromUglyUrl(String uglyUrl) { - String start = "www.justserve.org*2Fprojects*2F"; - if (!uglyUrl.contains(start)) { + String startAsterisk = "www.justserve.org*2Fprojects*2F"; + String startPercent = "www.justserve.org%2Fprojects%2F"; + String splitString = ""; + + if (uglyUrl.contains(startAsterisk)) { + splitString = startAsterisk; + } else if (uglyUrl.contains(startPercent)) { + splitString = startPercent; + } else { return null; } - String uuid = uglyUrl.split(Pattern.quote(start))[1].split(Pattern.quote("/"))[0]; + + String uuid = uglyUrl.split(Pattern.quote(splitString))[1].split(Pattern.quote("/"))[0]; return UUID.fromString(uuid); } } diff --git a/cli/src/test/groovy/org/justserve/util/EmailParserSpec.groovy b/cli/src/test/groovy/org/justserve/util/EmailParserSpec.groovy index fd20821..9b8b4cb 100644 --- a/cli/src/test/groovy/org/justserve/util/EmailParserSpec.groovy +++ b/cli/src/test/groovy/org/justserve/util/EmailParserSpec.groovy @@ -3,7 +3,10 @@ package org.justserve.util import io.micronaut.core.io.ResourceResolver import io.micronaut.test.extensions.spock.annotation.MicronautTest import jakarta.inject.Inject +import net.datafaker.Faker import org.jsoup.nodes.Document +import org.justserve.TestUser +import org.justserve.model.ProjectCard import spock.lang.Shared import spock.lang.Specification @@ -26,7 +29,7 @@ class EmailParserSpec extends Specification { def setupSpec() { testEmails = new HashMap<>() - + Faker faker = new Faker() TestUser recipient = new TestUser(faker) List mockProjects = [ @@ -35,6 +38,7 @@ class EmailParserSpec extends Specification { ] testEmails.put("test-with-automated-email", TestEmailGenerator.generateMockValidEmlContent(mockProjects, recipient)) + testEmails.put("test-with-automated-email-zendesk", TestEmailGenerator.generateMockZendeskEmlContent(mockProjects, recipient)) testEmails.put("test-without-automated-email", TestEmailGenerator.generateInvalidMockEmlContent()) testTrackingUrls = new HashMap<>() @@ -63,7 +67,7 @@ class EmailParserSpec extends Specification { return } def error = thrown(JustServeEmailParserError) - error.message == "Email is not a JustServe generated email." + error.message == "Email does not contain an HTML body." where: @@ -112,7 +116,7 @@ class EmailParserSpec extends Specification { return } def error = thrown(JustServeEmailParserError) - error.message == "Email is not a JustServe generated email." + error.message == "Email does not contain an HTML body." where: [title, fileContent] << testEmails.collect { key, value -> [key, value] } diff --git a/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy b/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy index bf1068a..57fb690 100644 --- a/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy +++ b/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy @@ -4,6 +4,8 @@ import net.datafaker.Faker import org.justserve.TestUser import org.justserve.model.ProjectCard +import java.util.concurrent.TimeUnit + class TestEmailGenerator { static String generateMockValidEmlContent(List projects, TestUser recipient) { @@ -18,7 +20,7 @@ class TestEmailGenerator { String phoneNumber = faker.phoneNumber().phoneNumber() sb.append("Return-Path: <").append(senderEmail).append(">\n") - sb.append("Date: ").append(faker.date().past(1, java.util.concurrent.TimeUnit.DAYS)).append("\n") + sb.append("Date: ").append(faker.date().past(1, TimeUnit.DAYS)).append("\n") sb.append("From: ").append(senderName).append(" <").append(senderEmail).append(">\n") sb.append("To: JustServeSupport \n") sb.append("Subject: Fwd: Project Reassignment\n") @@ -175,7 +177,7 @@ class TestEmailGenerator { String senderEmail = faker.internet().emailAddress() sb.append("Return-Path: <").append(senderEmail).append(">\n") - sb.append("Date: ").append(faker.date().past(1, java.util.concurrent.TimeUnit.DAYS)).append("\n") + sb.append("Date: ").append(faker.timeAndDate().past(1, java.util.concurrent.TimeUnit.DAYS)).append("\n") sb.append("From: ").append(senderName).append(" <").append(senderEmail).append(">\n") sb.append("To: JustServeSupport \n") sb.append("Subject: Random user email without JS content\n") @@ -190,5 +192,92 @@ class TestEmailGenerator { return sb.toString() } + + static String generateMockZendeskEmlContent(List projects, TestUser recipient) { + StringBuilder sb = new StringBuilder() + Faker faker = new Faker() + + String senderName = faker.name().fullName() + String senderEmail = faker.internet().emailAddress() + String recipientName = recipient.firstName + " " + recipient.lastName + String recipientEmail = recipient.email + String boundary = "000000000000" + faker.internet().uuid().replace("-", "").substring(0, 16) + + sb.append("Return-Path: <").append(senderEmail).append(">\n") + sb.append("Date: ").append(faker.timeAndDate().past(1, java.util.concurrent.TimeUnit.DAYS)).append("\n") + sb.append("From: ").append(senderName).append(" <").append(senderEmail).append(">\n") + sb.append("To: JustServeSupport \n") + sb.append("Subject: Fwd: Project Reassignment\n") + sb.append("Mime-Version: 1.0\n") + sb.append("Content-Type: multipart/alternative; boundary=\"").append(boundary).append("\"\n\n") + + // Plain text part + sb.append("--").append(boundary).append("\n") + sb.append("Content-Type: text/plain; charset=\"UTF-8\"\n") + sb.append("Content-Transfer-Encoding: quoted-printable\n\n") + + sb.append("CCJSS ").append(faker.name().fullName()).append(" reassigned ").append(projects.size()).append(" projects to their lead JSS account, ").append(recipientName).append(".\n\n") + sb.append("---------- Forwarded message ---------\n") + sb.append("From: JustServe.org \n") + sb.append("Date: Thu, Mar 12, 2026 at 1:43 PM\n") + sb.append("Subject: Project Reassignment\n") + sb.append("To: <").append(recipientEmail).append(">\n\n") + sb.append("Project Reassignment\n\n") + sb.append("The following projects have been reassigned from ").append(faker.name().fullName()).append(", to ").append(recipientName).append(", by ").append(senderName).append(":\n\n") + + projects.each { project -> + sb.append(" - ").append(project.title).append("\n") + sb.append(" \n") + } + + sb.append("\n").append(recipientName).append(" can now access these projects in their manage projects page for editing.\n\n") + + // HTML part + sb.append("--").append(boundary).append("\n") + sb.append("Content-Type: text/html; charset=\"UTF-8\"\n") + sb.append("Content-Transfer-Encoding: quoted-printable\n\n") + + String htmlTemplateStart = """
CCJSS ${faker.name().fullName()} reassigned ${projects.size()} projects to their lead JSS account, ${recipientName}.

---------- Forwarded message ---------
From: JustServe.org <noreply-js@mail.justserve.org>
Date: Thu, Mar 12, 2026 at 1:43 PM
Subject: Project Reassignment
To: <${recipientEmail}>


+
+ + + + + + + + +
+ + + + + + + + + +
+ Logo Title +
+
    """ + + String htmlTemplateEnd = """
+
+
+
""" + + sb.append(htmlTemplateStart) + + projects.each { project -> + String uglyUrl = "https://v6q93rxd.r.us-east-1.awstrack.me/L0/https:%2F%2Fwww.justserve.org%2Fprojects%2F" + project.id + "/1/0100019ce325c14c" + sb.append("
  • ").append(project.title).append("
  • ") + } + + sb.append(htmlTemplateEnd) + + sb.append("\n--").append(boundary).append("--\n") + return sb.toString() + } } diff --git a/cli/src/test/resources/README.md b/cli/src/test/resources/README.md deleted file mode 100644 index 7286b47..0000000 --- a/cli/src/test/resources/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Test Email Files - -To properly run the `EmailParserSpec` tests, you need to provide your own `.eml` files in this directory, as the original files contain Personally Identifiable Information (PII) and cannot be committed to Git. - -Please include two types of email files: - -1. **With Automated Content:** An email that **contains** the standard JustServe automated email footer. The filename for this file must include the word `with`. - * Example: `email-with-content.eml` - -2. **Without Automated Content:** An email that **does not contain** the standard JustServe automated email footer. The filename for this file must include the word `without`. - * Example: `email-without-content.eml` - -The tests are designed to dynamically find and parse any `.eml` files in this directory and will assert different outcomes based on whether "with" or "without" is present in the filename. From 474bfcef66f1302fe8a96f1f0df9f22c52e040a0 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Mon, 23 Mar 2026 11:18:56 -0600 Subject: [PATCH 03/12] test: generate all link types Signed-off-by: jonathan zollinger --- .../java/org/justserve/util/EmailParser.java | 5 ++- .../org/justserve/util/EmailParserSpec.groovy | 25 ++++++++++++- .../justserve/util/TestEmailGenerator.groovy | 35 +++++++++++++------ 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/cli/src/main/java/org/justserve/util/EmailParser.java b/cli/src/main/java/org/justserve/util/EmailParser.java index ddc533a..fb2a247 100644 --- a/cli/src/main/java/org/justserve/util/EmailParser.java +++ b/cli/src/main/java/org/justserve/util/EmailParser.java @@ -162,12 +162,15 @@ private static String getHtmlBody(Part part) throws MessagingException, IOExcept static UUID getProjectIDFromUglyUrl(String uglyUrl) { String startAsterisk = "www.justserve.org*2Fprojects*2F"; String startPercent = "www.justserve.org%2Fprojects%2F"; - String splitString = ""; + String startSlash = "www.justserve.org/projects/"; + String splitString; if (uglyUrl.contains(startAsterisk)) { splitString = startAsterisk; } else if (uglyUrl.contains(startPercent)) { splitString = startPercent; + } else if (uglyUrl.contains(startSlash)) { + splitString = startSlash; } else { return null; } diff --git a/cli/src/test/groovy/org/justserve/util/EmailParserSpec.groovy b/cli/src/test/groovy/org/justserve/util/EmailParserSpec.groovy index 9b8b4cb..319f66e 100644 --- a/cli/src/test/groovy/org/justserve/util/EmailParserSpec.groovy +++ b/cli/src/test/groovy/org/justserve/util/EmailParserSpec.groovy @@ -7,11 +7,12 @@ import net.datafaker.Faker import org.jsoup.nodes.Document import org.justserve.TestUser import org.justserve.model.ProjectCard +import org.justserve.util.TestEmailGenerator.UrlStyle import spock.lang.Shared import spock.lang.Specification +import spock.lang.Unroll import java.util.stream.Collectors -import java.util.stream.Stream @MicronautTest class EmailParserSpec extends Specification { @@ -121,4 +122,26 @@ class EmailParserSpec extends Specification { where: [title, fileContent] << testEmails.collect { key, value -> [key, value] } } + + @Unroll + def "Can parse project URLs with different encoding styles"(UrlStyle urlStyle) { + given: + Faker faker = new Faker() + TestUser recipient = new TestUser(faker) + List myMockProjects = [ + new ProjectCard(id: UUID.randomUUID(), title: faker.book().title()), + new ProjectCard(id: UUID.randomUUID(), title: faker.book().title()) + ] + String emailContent = TestEmailGenerator.generateMockValidEmlContent(myMockProjects, recipient, urlStyle) + + when: + Map> projects = EmailParser.getProjects(emailContent) + + then: + projects.size() == 2 + myMockProjects.every { mock -> projects.values().flatten().contains(mock.id) } + + where: + urlStyle << UrlStyle.values() + } } diff --git a/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy b/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy index 57fb690..062932a 100644 --- a/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy +++ b/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy @@ -4,23 +4,28 @@ import net.datafaker.Faker import org.justserve.TestUser import org.justserve.model.ProjectCard -import java.util.concurrent.TimeUnit +import static java.util.concurrent.TimeUnit.DAYS class TestEmailGenerator { - static String generateMockValidEmlContent(List projects, TestUser recipient) { + enum UrlStyle { + CLEAN, + ENCODED_DEFENSE, + ENCODED_AWS + } + + static String generateMockValidEmlContent(List projects, TestUser recipient, UrlStyle urlStyle = UrlStyle.ENCODED_AWS) { StringBuilder sb = new StringBuilder() Faker faker = new Faker() String recipientName = recipient.firstName + " " + recipient.lastName - String recipientEmail = recipient.email String senderName = faker.name().fullName() String senderEmail = faker.internet().emailAddress() String boundary = "000000000000" + faker.internet().uuid().replace("-", "").substring(0, 16) String phoneNumber = faker.phoneNumber().phoneNumber() sb.append("Return-Path: <").append(senderEmail).append(">\n") - sb.append("Date: ").append(faker.date().past(1, TimeUnit.DAYS)).append("\n") + sb.append("Date: ").append(faker.date().past(1, DAYS)).append("\n") sb.append("From: ").append(senderName).append(" <").append(senderEmail).append(">\n") sb.append("To: JustServeSupport \n") sb.append("Subject: Fwd: Project Reassignment\n") @@ -153,14 +158,25 @@ class TestEmailGenerator { -


    --
    ${senderName}
    JustServe Assistant Area Director
    ${faker.address().state()} 
    ${phoneNumber}  Cell and Text



    +


    --
    ${senderName}
    JustServe Assistant Area Director
    ${faker.address().state()} 
    ${phoneNumber}  Cell and Text



    """ sb.append(htmlTemplateStart) projects.each { project -> - String uglyUrl = "https://urldefense.com/v3/__https://v6q93rxd.r.us-east-1.awstrack.me/L0/https:*2F*2Fwww.justserve.org*2Fprojects*2F" + project.id + "/1/0100019b27fd352e-a7b03409-4c41-4863-b76a-524b7f84f180-000000/DNbIEdheshbb39W79A1Ru2ox05c=3D457__;JSUlJQ!!Oz_3W2l6Vjs!5dejAA5tmpGXmMs_vdlbrwn4wHWu5ytbEcsfO4rx9OUup3ka-dRHFZEinoeDKzwDHUqRP5WvSOsZ5sS1l875dafyqVS6\$" - sb.append("
  • ").append(project.title).append("
  • ") + String url + switch (urlStyle) { + case UrlStyle.ENCODED_DEFENSE: + url = "https://urldefense.com/v3/__https://www.justserve.org*2Fprojects*2F" + project.id + "/1/0100019cd9daccde-30d2f0ce-ca96-45a1-bd36-1cccc809eb61-000000/" + break + case UrlStyle.ENCODED_AWS: + url = "https://v6q93rxd.r.us-east-1.awstrack.me/L0/https:%2F%2Fwww.justserve.org%2Fprojects%2F" + project.id + "/1/0100019ce325c14c" + break + default: // UrlStyle.CLEAN + url = "https://www.justserve.org/projects/" + project.id + break + } + sb.append("
  • ").append(project.title).append("
  • ") } sb.append(htmlTemplateEnd) @@ -177,7 +193,7 @@ class TestEmailGenerator { String senderEmail = faker.internet().emailAddress() sb.append("Return-Path: <").append(senderEmail).append(">\n") - sb.append("Date: ").append(faker.timeAndDate().past(1, java.util.concurrent.TimeUnit.DAYS)).append("\n") + sb.append("Date: ").append(faker.timeAndDate().past(1, DAYS)).append("\n") sb.append("From: ").append(senderName).append(" <").append(senderEmail).append(">\n") sb.append("To: JustServeSupport \n") sb.append("Subject: Random user email without JS content\n") @@ -204,7 +220,7 @@ class TestEmailGenerator { String boundary = "000000000000" + faker.internet().uuid().replace("-", "").substring(0, 16) sb.append("Return-Path: <").append(senderEmail).append(">\n") - sb.append("Date: ").append(faker.timeAndDate().past(1, java.util.concurrent.TimeUnit.DAYS)).append("\n") + sb.append("Date: ").append(faker.timeAndDate().past(1, DAYS)).append("\n") sb.append("From: ").append(senderName).append(" <").append(senderEmail).append(">\n") sb.append("To: JustServeSupport \n") sb.append("Subject: Fwd: Project Reassignment\n") @@ -280,4 +296,3 @@ class TestEmailGenerator { return sb.toString() } } - From ef0a598f37492c2c916e54aff370774fb630c33c Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Mon, 23 Mar 2026 11:21:18 -0600 Subject: [PATCH 04/12] docs: clarify usage Signed-off-by: jonathan zollinger --- cli/src/main/java/org/justserve/util/EmailParser.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/main/java/org/justserve/util/EmailParser.java b/cli/src/main/java/org/justserve/util/EmailParser.java index fb2a247..5e9f548 100644 --- a/cli/src/main/java/org/justserve/util/EmailParser.java +++ b/cli/src/main/java/org/justserve/util/EmailParser.java @@ -65,7 +65,7 @@ public static Document parse(String emlFileContent) throws MessagingException, I * Extracts project names and their corresponding UUIDs for project ids on JustServe. * The Document is expected to represent an HTML email body from an automated JustServe email regarding reassigned projects * - * @param doc The Jsoup Document containing the HTML structure of the email. + * @param doc The Jsoup Document containing the HTML structure of the email. This is to be the html from the automated email and does not contain any other parts of the email. * @return A map where keys are project names (String) and values are project UUIDs. * @throws JustServeEmailParserError If the HTML structure does not conform to the expected format for extracting projects. */ From 54482f03a963426954b14cf1d8285525376e112a Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Mon, 23 Mar 2026 11:21:41 -0600 Subject: [PATCH 05/12] docs: remove boilerplate README Signed-off-by: jonathan zollinger --- core/README.md | 35 ----------------------------------- 1 file changed, 35 deletions(-) delete mode 100644 core/README.md diff --git a/core/README.md b/core/README.md deleted file mode 100644 index 9ea3d5e..0000000 --- a/core/README.md +++ /dev/null @@ -1,35 +0,0 @@ -## Micronaut 4.10.9 Documentation - -- [User Guide](https://docs.micronaut.io/4.10.9/guide/index.html) -- [API Reference](https://docs.micronaut.io/4.10.9/api/index.html) -- [Configuration Reference](https://docs.micronaut.io/4.10.9/guide/configurationreference.html) -- [Micronaut Guides](https://guides.micronaut.io/index.html) ---- - -- [Micronaut Gradle Plugin documentation](https://micronaut-projects.github.io/micronaut-gradle-plugin/latest/) -- [GraalVM Gradle Plugin documentation](https://graalvm.github.io/native-build-tools/latest/gradle-plugin.html) -- [Shadow Gradle Plugin](https://gradleup.com/shadow/) -## Feature serialization-jackson documentation - -- [Micronaut Serialization Jackson Core documentation](https://micronaut-projects.github.io/micronaut-serialization/latest/guide/) - - -## Feature micronaut-aot documentation - -- [Micronaut AOT documentation](https://micronaut-projects.github.io/micronaut-aot/latest/guide/) - - -## Feature lombok documentation - -- [Micronaut Project Lombok documentation](https://docs.micronaut.io/latest/guide/index.html#lombok) - -- [https://projectlombok.org/features/all](https://projectlombok.org/features/all) - - -## Feature openapi documentation - -- [Micronaut OpenAPI Support documentation](https://micronaut-projects.github.io/micronaut-openapi/latest/guide/index.html) - -- [https://www.openapis.org](https://www.openapis.org) - - From 4c55dbc8c42e1e771be67be0e4e8f27b0eb8899b Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Mon, 23 Mar 2026 11:28:58 -0600 Subject: [PATCH 06/12] fix: remove deprecated faker.date() Signed-off-by: jonathan zollinger --- .../test/groovy/org/justserve/util/TestEmailGenerator.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy b/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy index 062932a..822655a 100644 --- a/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy +++ b/cli/src/test/groovy/org/justserve/util/TestEmailGenerator.groovy @@ -25,7 +25,7 @@ class TestEmailGenerator { String phoneNumber = faker.phoneNumber().phoneNumber() sb.append("Return-Path: <").append(senderEmail).append(">\n") - sb.append("Date: ").append(faker.date().past(1, DAYS)).append("\n") + sb.append("Date: ").append(faker.timeAndDate().past(1, DAYS)).append("\n") sb.append("From: ").append(senderName).append(" <").append(senderEmail).append(">\n") sb.append("To: JustServeSupport \n") sb.append("Subject: Fwd: Project Reassignment\n") From 81c69c6a52f92df2ada2b15be4f0049fccd5c7f2 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Mon, 23 Mar 2026 12:17:56 -0600 Subject: [PATCH 07/12] docs: clarify expectations in testing Signed-off-by: jonathan zollinger --- .../groovy/org/justserve/cli/command/GetTempPasswordSpec.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/test/groovy/org/justserve/cli/command/GetTempPasswordSpec.groovy b/cli/src/test/groovy/org/justserve/cli/command/GetTempPasswordSpec.groovy index 65ffe81..c2b83bb 100644 --- a/cli/src/test/groovy/org/justserve/cli/command/GetTempPasswordSpec.groovy +++ b/cli/src/test/groovy/org/justserve/cli/command/GetTempPasswordSpec.groovy @@ -12,7 +12,7 @@ import static org.spockframework.runtime.model.parallel.ExecutionMode.SAME_THREA class GetTempPasswordSpec extends BaseCommandSpec { - @Unroll("getting temp password with '#flag' and '#email' returns ") + @Unroll("getting temp password with '#flag' and '#email' #expectation") def "commands to query temporary password should behave as expected with or without authentication"() { when: def (outputStream, errorStream) = executeCommand(context as ApplicationContext, ["getTempPassword", flag, email] as String[]) From 9729707cdf2ba5f4490facd41fa34bda89efd2e2 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Mon, 23 Mar 2026 14:05:42 -0600 Subject: [PATCH 08/12] fix: include "" as unauthenticated tokens Signed-off-by: jonathan zollinger --- cli/src/main/java/org/justserve/command/BaseCommand.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/main/java/org/justserve/command/BaseCommand.java b/cli/src/main/java/org/justserve/command/BaseCommand.java index 18bb55b..04a1658 100644 --- a/cli/src/main/java/org/justserve/command/BaseCommand.java +++ b/cli/src/main/java/org/justserve/command/BaseCommand.java @@ -24,7 +24,7 @@ public class BaseCommand implements ConsoleOutput { String token; boolean isTokenInvalid() { - if ("i-need-to-be-defined".equals(token) || null == token) { + if ("i-need-to-be-defined".equals(token) || null == token || token.isEmpty()) { printError(("NO AUTHENTICATION PROVIDED" + System.lineSeparator() + "The Authentication token is not assigned as an environment variable." + System.lineSeparator() + "Please define the environment variable \"JUSTSERVE_TOKEN\" and try again.")); From 8075513c063cbe0e941603a4a41e3e71b68b1a00 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Mon, 23 Mar 2026 14:33:59 -0600 Subject: [PATCH 09/12] fix: call createOrg with no args Signed-off-by: jonathan zollinger --- .../org/justserve/cli/command/MakeOrgAdminSpec.groovy | 10 ---------- .../src/test/groovy/org/justserve/JustServeSpec.groovy | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/cli/src/test/groovy/org/justserve/cli/command/MakeOrgAdminSpec.groovy b/cli/src/test/groovy/org/justserve/cli/command/MakeOrgAdminSpec.groovy index fc92d8b..06a5820 100644 --- a/cli/src/test/groovy/org/justserve/cli/command/MakeOrgAdminSpec.groovy +++ b/cli/src/test/groovy/org/justserve/cli/command/MakeOrgAdminSpec.groovy @@ -90,16 +90,6 @@ class MakeOrgAdminSpec extends BaseCommandSpec { if (orgCount == 1) { orgs = fakeSlug } else { - // We can't easily get slugs for the sharedOrgs since they are just UUIDs in the list. - // However, the original test was searching for orgs by location to get slugs. - // To keep it fast, we can just fetch one set of slugs in setupSpec if needed, - // or just do the search once here if we really need slugs. - // But wait, createTestOrgs returns UUIDs. - // The original test used: authOrgClient.searchByLocation(...).getOrganizations().url - // Let's just do that search once in the test method, but limit it. - // Actually, better yet, let's just use the search here but only call it if orgCount > 1. - // Since we are optimizing, let's try to reuse the search result if possible, but for now - // let's stick to the original logic for the slug part but reuse the user. orgs = authOrgClient.searchByLocation(createSearchRequestForElkGrove()).body().getOrganizations().url.take(orgCount - 1).join(",") + "," + fakeSlug } diff --git a/core/src/test/groovy/org/justserve/JustServeSpec.groovy b/core/src/test/groovy/org/justserve/JustServeSpec.groovy index 834f337..b5defb5 100644 --- a/core/src/test/groovy/org/justserve/JustServeSpec.groovy +++ b/core/src/test/groovy/org/justserve/JustServeSpec.groovy @@ -175,7 +175,7 @@ class JustServeSpec extends Specification { * @return A list of UUIDs for the created organizations. */ List createTestOrgs(int count) { - return (1..count).toList().parallelStream().map(this::createOrg).collect(Collectors.toList()) + return (1..count).toList().parallelStream().map({ _ -> createOrg() }).collect(Collectors.toList()) } From 833158c04cfac068dfe51fcde66e486caf0caef3 Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Mon, 23 Mar 2026 15:18:55 -0600 Subject: [PATCH 10/12] fix: handle null project response Signed-off-by: jonathan zollinger --- .../justserve/command/UnReassignProjects.java | 5 ++ .../cli/command/UnReassignProjectsSpec.groovy | 67 +++++++++++++------ 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/cli/src/main/java/org/justserve/command/UnReassignProjects.java b/cli/src/main/java/org/justserve/command/UnReassignProjects.java index c8ba5b9..77e23a3 100644 --- a/cli/src/main/java/org/justserve/command/UnReassignProjects.java +++ b/cli/src/main/java/org/justserve/command/UnReassignProjects.java @@ -90,6 +90,11 @@ public void run() { log.atError().setCause(e).log("Error getting project"); continue; } + if (null == project) { + printError("Failed to get project '" + projectName + "' (" + projectId + ")"); + log.atError().log("Project {} not found", projectId); + continue; + } if (null == project.getProjectOwnerUserId()) { warning(String.format("Project %s (%s) has no owner", projectName, projectId)); log.warn("Project {} ({}) has no owner", projectName, projectId); diff --git a/cli/src/test/groovy/org/justserve/cli/command/UnReassignProjectsSpec.groovy b/cli/src/test/groovy/org/justserve/cli/command/UnReassignProjectsSpec.groovy index a680f2d..b6658bd 100644 --- a/cli/src/test/groovy/org/justserve/cli/command/UnReassignProjectsSpec.groovy +++ b/cli/src/test/groovy/org/justserve/cli/command/UnReassignProjectsSpec.groovy @@ -1,43 +1,37 @@ package org.justserve.cli.command -import io.micronaut.core.io.ResourceResolver + import io.micronaut.test.extensions.spock.annotation.MicronautTest -import jakarta.inject.Inject +import org.justserve.TestUser +import org.justserve.model.ProjectCard import org.justserve.util.EmailParser +import org.justserve.util.TestEmailGenerator import spock.lang.Execution import spock.lang.Shared -import java.util.stream.Collectors -import java.util.stream.Stream - import static org.spockframework.runtime.model.parallel.ExecutionMode.SAME_THREAD @Execution(SAME_THREAD) @MicronautTest class UnReassignProjectsSpec extends BaseCommandSpec { - @Inject - @Shared - ResourceResolver resourceResolver - @Shared File tempEmlFile @Shared Map testEmails + @Shared + List testProjects + def setupSpec() { + testProjects = getProjectsByLocation(faker.location().toString()) + def newReadOnlyUser = new TestUser(faker) + newReadOnlyUser.uuid = createUser().body().id testEmails = new HashMap<>() - Stream.of("sara-anderson-email.eml", "test-with-automated-email.eml", "test-without-automated-email.eml").forEach { file -> - def resource = resourceResolver.getResourceAsStream("classpath:$file") - resource.ifPresent { stream -> - try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { - testEmails.put(file.replace(".eml", ""), reader.lines().collect(Collectors.joining(System.lineSeparator()))) - } catch (IOException e) { - throw new RuntimeException("Failed to read test file: $file", e) - } - } - } + testEmails.put("forwarded-reassignment-email", TestEmailGenerator.generateMockValidEmlContent(testProjects, readOnlyUser)) + testEmails.put("automated-reassignment-email", TestEmailGenerator.generateMockValidEmlContent(testProjects, newReadOnlyUser)) + testEmails.put("email-without-justserve-content", TestEmailGenerator.generateInvalidMockEmlContent()) } def "can make reassignments from #title to a user"(String title, String fileContent) { @@ -45,8 +39,9 @@ class UnReassignProjectsSpec extends BaseCommandSpec { if (title.contains("without")) { return } - String testFile = new File(resourceResolver.getResource("classpath:${title}.eml").get().toURI()).absolutePath - def args = ["unReassignProjects", "-u", readOnlyUser.uuid.toString(), "-f", testFile] + tempEmlFile = File.createTempFile(title, ".eml") + tempEmlFile.write(fileContent) + def args = ["unReassignProjects", "-u", readOnlyUser.uuid.toString(), "-f", tempEmlFile.absolutePath] def projectCount = EmailParser.getProjects(fileContent).values().flatten().size() when: @@ -54,12 +49,40 @@ class UnReassignProjectsSpec extends BaseCommandSpec { then: errorStream.matches(blankRegex) - projects.each { project -> + testProjects.each { project -> outputStream.contains(project.id.toString()) } outputStream.contains("Successfully reassigned ${projectCount} projects to user ${readOnlyUser.uuid}") + cleanup: + try { + tempEmlFile.delete() + } catch (Exception ignored) { + } + where: [title, fileContent] << testEmails.collect { key, value -> [key, value] } } + + def "shows error when project ID is invalid"() { + given: + def invalidProject = new ProjectCard().setId(UUID.randomUUID()).setTitle("Surprised by Joy") + 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] + + when: + def (outputStream, errorStream) = executeCommand(ctx, args as String[]) + + then: + errorStream.contains("Failed to get project 'Surprised by Joy' (${invalidProject.id})") + outputStream.contains("Successfully reassigned 0 projects") + + cleanup: + try { + tempEmlFile.delete() + } catch (Exception ignored) { + } + } } From 2ae17d16650bdba703ba0c57c4d61e054d557a4d Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Mon, 23 Mar 2026 15:52:49 -0600 Subject: [PATCH 11/12] refactor: put only try/catch api call in question replace local eml file need with generated files Signed-off-by: jonathan zollinger --- .../justserve/command/UnReassignProjects.java | 28 ++++++++++--------- .../cli/command/UnReassignProjectsSpec.groovy | 19 ++++++------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/cli/src/main/java/org/justserve/command/UnReassignProjects.java b/cli/src/main/java/org/justserve/command/UnReassignProjects.java index 77e23a3..b1c58c4 100644 --- a/cli/src/main/java/org/justserve/command/UnReassignProjects.java +++ b/cli/src/main/java/org/justserve/command/UnReassignProjects.java @@ -104,24 +104,26 @@ public void run() { log.warn("Project {} ({}) is already assigned to user {}", projectName, projectId, userID); continue; } + ReassignProjectRequest reassignProjectRequest = new ReassignProjectRequest(userID, project.getProjectOwnerUserId()); + log.atTrace().log("Reassigning project {} ({}) to user {}", projectName, projectId, userID); + HttpResponse reassignResponse = null; try { - ReassignProjectRequest reassignProjectRequest = new ReassignProjectRequest(userID, project.getProjectOwnerUserId()); - log.atTrace().log("Reassigning project {} ({}) to user {}", projectName, projectId, userID); - HttpResponse reassignResponse = client.reassignProject(projectId, reassignProjectRequest); - if (reassignResponse.status() == HttpStatus.OK) { - printNormal("Successfully reassigned project %s (%s) to user %s", projectName, projectId, userID); - log.atTrace().log("received api response status: {}", reassignResponse.status()); - successCount++; - continue; - } - printError("Failed to reassign project " + projectName + " (" + projectId + ") to user " + userID + - ". Expected HTTP Status 'OK', but got " + reassignResponse.status()); - log.atError().log("Failed to reassign project {} ({}) to user {}. Expected HTTP Status 'OK', but got {}", - projectName, projectId, userID, reassignResponse.status()); + reassignResponse = client.reassignProject(projectId, reassignProjectRequest); } catch (HttpClientResponseException e) { printError("Failed to reassign project " + projectName + " (" + projectId + ") to user " + userID); log.atError().setCause(e).log("Error response from API: {}", e.getResponse().body()); } + if (null != reassignResponse && reassignResponse.status() == HttpStatus.OK) { + printNormal("Successfully reassigned project %s (%s) to user %s", projectName, projectId, userID); + log.atTrace().log("received api response status: {}", reassignResponse.status()); + successCount++; + continue; + } + String reason = reassignResponse == null ? "response is null" : reassignResponse.status().toString(); + printError("Failed to reassign project " + projectName + " (" + projectId + ") to user " + userID + + ". Expected HTTP Status 'OK', but got " + reason); + log.atError().log("Failed to reassign project {} ({}) to user {}. Expected HTTP Status 'OK', but got {}", + projectName, projectId, userID, reason); } } printNormal("Successfully reassigned %d projects to user %s", successCount, userID); diff --git a/cli/src/test/groovy/org/justserve/cli/command/UnReassignProjectsSpec.groovy b/cli/src/test/groovy/org/justserve/cli/command/UnReassignProjectsSpec.groovy index b6658bd..75053a8 100644 --- a/cli/src/test/groovy/org/justserve/cli/command/UnReassignProjectsSpec.groovy +++ b/cli/src/test/groovy/org/justserve/cli/command/UnReassignProjectsSpec.groovy @@ -19,7 +19,7 @@ class UnReassignProjectsSpec extends BaseCommandSpec { File tempEmlFile @Shared - Map testEmails + Map testEmails @Shared List testProjects @@ -29,19 +29,19 @@ class UnReassignProjectsSpec extends BaseCommandSpec { def newReadOnlyUser = new TestUser(faker) newReadOnlyUser.uuid = createUser().body().id testEmails = new HashMap<>() - testEmails.put("forwarded-reassignment-email", TestEmailGenerator.generateMockValidEmlContent(testProjects, readOnlyUser)) - testEmails.put("automated-reassignment-email", TestEmailGenerator.generateMockValidEmlContent(testProjects, newReadOnlyUser)) - testEmails.put("email-without-justserve-content", TestEmailGenerator.generateInvalidMockEmlContent()) + testEmails.put("forwarded-reassignment-email", [TestEmailGenerator.generateMockValidEmlContent(testProjects, readOnlyUser), readOnlyUser]) + testEmails.put("automated-reassignment-email", [TestEmailGenerator.generateMockValidEmlContent(testProjects, newReadOnlyUser), newReadOnlyUser]) + testEmails.put("email-without-justserve-content", [TestEmailGenerator.generateInvalidMockEmlContent(), readOnlyUser]) } - def "can make reassignments from #title to a user"(String title, String fileContent) { + def "can make reassignments from #title to a user"(String title, String fileContent, TestUser user) { given: if (title.contains("without")) { return } tempEmlFile = File.createTempFile(title, ".eml") tempEmlFile.write(fileContent) - def args = ["unReassignProjects", "-u", readOnlyUser.uuid.toString(), "-f", tempEmlFile.absolutePath] + def args = ["unReassignProjects", "-u", user.uuid.toString(), "-f", tempEmlFile.absolutePath] def projectCount = EmailParser.getProjects(fileContent).values().flatten().size() when: @@ -49,10 +49,9 @@ class UnReassignProjectsSpec extends BaseCommandSpec { then: errorStream.matches(blankRegex) - testProjects.each { project -> - outputStream.contains(project.id.toString()) + testProjects.each { project -> outputStream.contains(project.id.toString()) } - outputStream.contains("Successfully reassigned ${projectCount} projects to user ${readOnlyUser.uuid}") + outputStream.contains("Successfully reassigned ${projectCount} projects to user ${user.uuid}") cleanup: try { @@ -61,7 +60,7 @@ class UnReassignProjectsSpec extends BaseCommandSpec { } where: - [title, fileContent] << testEmails.collect { key, value -> [key, value] } + [title, fileContent, user] << testEmails.collect { key, value -> [key, value[0], value[1]] } } def "shows error when project ID is invalid"() { From 80d20b74f381de492f3e9eedaf0770c07e99e70a Mon Sep 17 00:00:00 2001 From: jonathan zollinger Date: Mon, 23 Mar 2026 16:08:25 -0600 Subject: [PATCH 12/12] fix(build): define justserve url Signed-off-by: jonathan zollinger --- cli/src/main/resources/application.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cli/src/main/resources/application.yml b/cli/src/main/resources/application.yml index 1bafcb1..e4cd39a 100644 --- a/cli/src/main/resources/application.yml +++ b/cli/src/main/resources/application.yml @@ -2,6 +2,10 @@ micronaut: application: name: justserve-cli version: "@justserveCliVersion@" + http: + services: + justserve: + url: https://www.justserve.org justserve: token: ${:i-need-to-be-defined} logger: