├── .gitattributes ├── .mvn ├── jvm.config └── wrapper │ └── maven-wrapper.properties ├── .github ├── FUNDING.yml ├── workflows │ ├── proof-html.yml │ ├── auto-assign.yml │ ├── maven.yml │ ├── maven-publish.yml │ ├── sonar.yml │ ├── node.js.yml │ ├── npm-publish-packages.yml │ ├── aws.yml │ ├── docker-publish.yml │ └── codeql.yml └── dependabot.yml ├── screen-grabs ├── jacoco.png ├── home-page.png ├── cli-docker.png ├── github-mark.png ├── graph-i-ql.png ├── react-tests.png ├── jacoco-browser.png ├── local-ui-proxy.png ├── IDEA-Intellj-run.png ├── npm-test-coverage.png ├── npm-test-coverage-report-1.png ├── npm-test-coverage-report-2.png └── npm-test-coverage-report-3.png ├── docker-compose.yml ├── frontend ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── robot-logo.png │ ├── manifest.json │ └── index.html ├── src │ ├── img │ │ ├── red-bg.png │ │ ├── teal-bg.png │ │ ├── robot-logo.png │ │ └── darkblue-bg.png │ ├── setupTests.js │ ├── components │ │ ├── __tests__ │ │ │ ├── Home.test.js │ │ │ ├── NotFound.test.js │ │ │ ├── About.test.js │ │ │ ├── CoffeeHome.test.js │ │ │ ├── CoffeCarousel.test.js │ │ │ ├── ControlledCarousel.test.js │ │ │ ├── Donate.test.js │ │ │ └── RepoIssues.test.js │ │ ├── NotFound.js │ │ ├── CoffeeHome.js │ │ ├── CoffeeCarousel.js │ │ ├── ControlledCarousel.js │ │ ├── About.js │ │ ├── Home.js │ │ └── Donate.js │ ├── index.css │ ├── reportWebVitals.js │ ├── index.js │ ├── LoadingSpinner.js │ ├── Footer.js │ ├── App.js │ ├── App.css │ └── App.test.js └── package.json ├── src ├── test │ ├── resources │ │ ├── json │ │ │ ├── test_parse_null_response.json │ │ │ ├── test_issues_response.json │ │ │ └── test_repo_detail_response.json │ │ └── graphql │ │ │ ├── query.graphql │ │ │ ├── charities.txt │ │ │ └── iRonocQuery.graphqls.SAMPLE │ └── java │ │ └── net │ │ └── ironoc │ │ └── portfolio │ │ ├── web │ │ └── page │ │ │ ├── BrewsPage.java │ │ │ ├── DonatePage.java │ │ │ ├── PortfolioPage.java │ │ │ ├── AboutPage.java │ │ │ └── HomePage.java │ │ ├── AppTest.java │ │ ├── aws │ │ └── AwsSecretManagerTest.java │ │ ├── config │ │ └── TestIronocConfiguration.java │ │ ├── utils │ │ ├── UrlUtilsTest.java │ │ └── TestRequestResponseUtils.java │ │ ├── controller │ │ ├── VersionControllerTest.java │ │ ├── BaseControllerIntegrationTest.java │ │ ├── DonateRestControllerTest.java │ │ ├── PortfolioControllerTest.java │ │ └── BrewGraphqlControllerTest.java │ │ ├── SeleniumConfig.java │ │ ├── service │ │ ├── GraphQLClientServiceTest.java │ │ ├── GitRepoCacheServiceTest.java │ │ └── PortfolioItemsResolverTest.java │ │ ├── job │ │ └── GitDetailsRunnableTest.java │ │ ├── graph │ │ └── BrewsResolverTest.java │ │ ├── RemoteBrowserBasedIntTest.java │ │ └── resolver │ │ └── PushStateResourceResolverTest.java └── main │ ├── java │ └── net │ │ └── ironoc │ │ └── portfolio │ │ ├── enums │ │ └── SortingOrder.java │ │ ├── aws │ │ ├── SecretManager.java │ │ └── AwsSecretManager.java │ │ ├── service │ │ ├── Coffees.java │ │ ├── AbstractGitCache.java │ │ ├── CoffeesCache.java │ │ ├── GitProjectCache.java │ │ ├── GitRepoCache.java │ │ ├── GraphQLClient.java │ │ ├── GitDetails.java │ │ ├── CoffeeCacheService.java │ │ ├── GitRepoCacheService.java │ │ ├── PortfolioItemsResolver.java │ │ ├── GitProjectCacheService.java │ │ ├── CoffeesService.java │ │ └── GraphQLClientService.java │ │ ├── logger │ │ ├── LoggerI.java │ │ └── AbstractLogger.java │ │ ├── App.java │ │ ├── dto │ │ ├── DonateItemOrder.java │ │ ├── Brew.java │ │ ├── OwnerDto.java │ │ ├── LabelDto.java │ │ ├── Donate.java │ │ ├── RepositoryIssueDto.java │ │ └── RepositoryDetailDto.java │ │ ├── exception │ │ └── IronocJsonException.java │ │ ├── client │ │ ├── Client.java │ │ └── GitClient.java │ │ ├── utils │ │ └── UrlUtils.java │ │ ├── config │ │ ├── PropertyKeyI.java │ │ ├── PropertyConfigI.java │ │ ├── GraphiQlConfiguration.java │ │ ├── Properties.java │ │ ├── PropertyKey.java │ │ ├── IronocConfiguration.java │ │ └── PropertyConfig.java │ │ ├── domain │ │ ├── IngredientsDeserializer.java │ │ ├── RepositoryIssueDomain.java │ │ ├── CoffeeDomain.java │ │ └── RepositoryDetailDomain.java │ │ ├── graph │ │ └── BrewsResolver.java │ │ ├── controller │ │ ├── VersionController.java │ │ ├── DonateRestController.java │ │ ├── PortfolioController.java │ │ └── BrewGraphqlController.java │ │ ├── job │ │ ├── GitDetailsJob.java │ │ └── GitDetailsRunnable.java │ │ └── resolver │ │ └── PushStateResourceResolver.java │ └── resources │ ├── graphql │ ├── coffeesQuery.graphqls │ ├── charities.txt │ └── ironocGraphSchema.graphql │ ├── static │ └── ironoc-banner.txt │ ├── json │ └── portfolio-items.json │ ├── application.yml │ └── graphiql │ └── index.html ├── .gitignore ├── run.sh ├── Dockerfile ├── publish └── Dockerfile ├── SECURITY.md └── .aws └── task-definition.json /.gitattributes: -------------------------------------------------------------------------------- 1 | src/main/webapp/templates/*.html linguist-language=Java 2 | -------------------------------------------------------------------------------- /.mvn/jvm.config: -------------------------------------------------------------------------------- 1 | -Xmx4G -Xms4G -Djava.awt.headless=true 2 | --show-version 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [conorheffron] 4 | -------------------------------------------------------------------------------- /screen-grabs/jacoco.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/screen-grabs/jacoco.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | portfolio: 3 | image: ironoc 4 | ports: 5 | - "8080:8080" -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /screen-grabs/home-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/screen-grabs/home-page.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/img/red-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/frontend/src/img/red-bg.png -------------------------------------------------------------------------------- /frontend/src/img/teal-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/frontend/src/img/teal-bg.png -------------------------------------------------------------------------------- /screen-grabs/cli-docker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/screen-grabs/cli-docker.png -------------------------------------------------------------------------------- /screen-grabs/github-mark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/screen-grabs/github-mark.png -------------------------------------------------------------------------------- /screen-grabs/graph-i-ql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/screen-grabs/graph-i-ql.png -------------------------------------------------------------------------------- /screen-grabs/react-tests.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/screen-grabs/react-tests.png -------------------------------------------------------------------------------- /frontend/public/robot-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/frontend/public/robot-logo.png -------------------------------------------------------------------------------- /frontend/src/img/robot-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/frontend/src/img/robot-logo.png -------------------------------------------------------------------------------- /screen-grabs/jacoco-browser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/screen-grabs/jacoco-browser.png -------------------------------------------------------------------------------- /screen-grabs/local-ui-proxy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/screen-grabs/local-ui-proxy.png -------------------------------------------------------------------------------- /frontend/src/img/darkblue-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/frontend/src/img/darkblue-bg.png -------------------------------------------------------------------------------- /screen-grabs/IDEA-Intellj-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/screen-grabs/IDEA-Intellj-run.png -------------------------------------------------------------------------------- /screen-grabs/npm-test-coverage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/screen-grabs/npm-test-coverage.png -------------------------------------------------------------------------------- /screen-grabs/npm-test-coverage-report-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/screen-grabs/npm-test-coverage-report-1.png -------------------------------------------------------------------------------- /screen-grabs/npm-test-coverage-report-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/screen-grabs/npm-test-coverage-report-2.png -------------------------------------------------------------------------------- /screen-grabs/npm-test-coverage-report-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/conorheffron/ironoc/HEAD/screen-grabs/npm-test-coverage-report-3.png -------------------------------------------------------------------------------- /src/test/resources/json/test_parse_null_response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "conorheffron/ironoc-test", 4 | "topics": [] 5 | } 6 | ] -------------------------------------------------------------------------------- /frontend/src/setupTests.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { TextEncoder } from 'util'; 3 | 4 | global.TextEncoder = TextEncoder; 5 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/enums/SortingOrder.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.enums; 2 | 3 | public enum SortingOrder { 4 | 5 | ASC, DESC 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/aws/SecretManager.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.aws; 2 | 3 | public interface SecretManager { 4 | 5 | String getGitSecret(); 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | /.classpath 3 | /.project 4 | /.env 5 | /.settings/ 6 | /.idea 7 | /logs 8 | /frontend/node 9 | /frontend/node_modules 10 | /frontend/build 11 | /frontend/coverage 12 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | wrapperVersion=3.3.4 2 | distributionType=only-script 3 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 4 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | JAVA_OPTS_DEFAULT="" 4 | JAVA_OPTS=${JAVA_OPTS:-${JAVA_OPTS_DEFAULT}} 5 | 6 | echo 7 | echo "java ${JAVA_OPTS} -jar /app.war" 8 | echo 9 | java ${JAVA_OPTS} -Djava.security.egd=file:/dev/./urandom -jar /app.war -------------------------------------------------------------------------------- /src/test/resources/graphql/query.graphql: -------------------------------------------------------------------------------- 1 | { 2 | allIceds { 3 | id 4 | ingredients 5 | image 6 | title 7 | }, 8 | allHots { 9 | id 10 | ingredients 11 | image 12 | title 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/resources/graphql/coffeesQuery.graphqls: -------------------------------------------------------------------------------- 1 | { 2 | allIceds { 3 | id 4 | ingredients 5 | image 6 | title 7 | }, 8 | allHots { 9 | id 10 | ingredients 11 | image 12 | title 13 | } 14 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM eclipse-temurin:25-alpine-3.22 2 | 3 | COPY target/*.war app.war 4 | RUN sh -c 'touch /app.war' 5 | 6 | ENV RUN_FILE /run.sh 7 | COPY run.sh ${RUN_FILE} 8 | RUN chmod +x ${RUN_FILE} 9 | 10 | EXPOSE 8080 11 | 12 | ENTRYPOINT [ "sh", "-c", "${RUN_FILE}" ] 13 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/service/Coffees.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import net.ironoc.portfolio.domain.CoffeeDomain; 6 | 7 | public interface Coffees { 8 | 9 | List getCoffeeDetails(); 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/proof-html.yml: -------------------------------------------------------------------------------- 1 | name: Proof HTML 2 | permissions: 3 | contents: read 4 | on: 5 | push: 6 | workflow_dispatch: 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: anishathalye/proof-html@v1.1.0 12 | with: 13 | directory: ./ 14 | -------------------------------------------------------------------------------- /publish/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM eclipse-temurin:25-alpine-3.22 2 | 3 | VOLUME /tmp 4 | 5 | #for docker pub 6 | COPY *.war app.war 7 | RUN sh -c 'touch /app.war' 8 | 9 | ENV RUN_FILE /run.sh 10 | COPY run.sh ${RUN_FILE} 11 | RUN chmod +x ${RUN_FILE} 12 | 13 | EXPOSE 8080 14 | 15 | ENTRYPOINT [ "sh", "-c", "${RUN_FILE}" ] 16 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/web/page/BrewsPage.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.web.page; 2 | 3 | import lombok.Getter; 4 | import org.openqa.selenium.WebDriver; 5 | 6 | @Getter 7 | public class BrewsPage { 8 | protected WebDriver driver; 9 | 10 | public BrewsPage(WebDriver driver) { 11 | this.driver = driver; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/logger/LoggerI.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.logger; 2 | 3 | public interface LoggerI { 4 | 5 | void info(String message, Object...values); 6 | 7 | void debug(String message, Object...values); 8 | 9 | void error(String message, Object...values); 10 | 11 | void warn(String message, Object...values); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/App.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class App { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(App.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | |----------| ------------------ | 7 | | 9.x.x | :white_check_mark: | 8 | | <= 8.x.x | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | Please submit issues [here](https://github.com/conorheffron/ironoc/issues) or contact conorheffron directly. 13 | -------------------------------------------------------------------------------- /src/test/resources/graphql/charities.txt: -------------------------------------------------------------------------------- 1 | The Jack and Jill Children’s Foundation 2 | Vision Ireland, the new name for NCBI 3 | Temple Street Children’s University Hospital 4 | Irish Cancer Society 5 | The Society of Saint Vincent de Paul 6 | Dublin Simon Community 7 | Debra Ireland 8 | Red Cross 9 | Save the Children 10 | UNICEF 11 | Doctors Without Borders 12 | Example Charity 13 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/service/AbstractGitCache.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | public abstract sealed class AbstractGitCache permits GitProjectCacheService, GitRepoCacheService { 6 | 7 | void clear(Map gitDetails) { 8 | gitDetails.clear(); 9 | } 10 | 11 | abstract void remove(String key); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/service/CoffeesCache.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import net.ironoc.portfolio.domain.CoffeeDomain; 6 | 7 | public interface CoffeesCache { 8 | void put(List repositoryIssueDomains); 9 | 10 | List get(); 11 | 12 | void remove(); 13 | 14 | void tearDown(); 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/components/__tests__/Home.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen, act } from '@testing-library/react'; 2 | import Home from '../Home'; 3 | 4 | describe('Home', () => { 5 | test('renders Home component', async () => { 6 | await act(async () => { 7 | render(); 8 | }); 9 | const element = screen.getByText(/Home/i); 10 | expect(element).toBeInTheDocument(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/dto/DonateItemOrder.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.dto; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | import net.ironoc.portfolio.enums.SortingOrder; 7 | 8 | @Data 9 | @NoArgsConstructor 10 | @AllArgsConstructor 11 | public class DonateItemOrder { 12 | 13 | private SortingOrder founded; 14 | 15 | private SortingOrder name; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/service/GitProjectCache.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import net.ironoc.portfolio.domain.RepositoryIssueDomain; 6 | 7 | public interface GitProjectCache { 8 | 9 | void put(String userId, String project, List repositoryIssueDomains); 10 | 11 | List get(String userId, String project); 12 | 13 | void remove(String key); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/exception/IronocJsonException.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.exception; 2 | 3 | import com.fasterxml.jackson.core.JsonProcessingException; 4 | 5 | public class IronocJsonException extends JsonProcessingException { 6 | 7 | public IronocJsonException(String message) { 8 | super(message); 9 | } 10 | 11 | public IronocJsonException(String message, Throwable cause) { 12 | super(message, cause); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/test/resources/json/test_issues_response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "number": "62", 4 | "title": "Re-write frontend with React \u003CPOC\u003E", 5 | "body": "Use React or Angular framework & JavaScript or TypeScript as implementation language? \r\n- Research & select best option." 6 | }, 7 | { 8 | "number": "57", 9 | "title": "Setup LB, Support SSL", 10 | "body": "- [x] 1. Setup LB\r\n- [ ] 2. Support SSL\r\n- [ ] 3. Setup domain, map to AWS LB" 11 | } 12 | ] -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/service/GitRepoCache.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import net.ironoc.portfolio.domain.RepositoryDetailDomain; 6 | import org.springframework.stereotype.Service; 7 | 8 | @Service 9 | public interface GitRepoCache { 10 | 11 | void put(String userId, List repositoryDetails); 12 | 13 | List get(String userId); 14 | 15 | void remove(String key); 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/service/GraphQLClient.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import com.fasterxml.jackson.core.JsonProcessingException; 6 | 7 | public interface GraphQLClient { 8 | Map fetchCoffeeDetails() throws JsonProcessingException; 9 | 10 | List> getAllIcedCoffees(Map response); 11 | 12 | List> getAllHotCoffees(Map response); 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/auto-assign.yml: -------------------------------------------------------------------------------- 1 | name: Auto Assign 2 | on: 3 | issues: 4 | types: [opened] 5 | pull_request: 6 | types: [opened] 7 | jobs: 8 | run: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | issues: write 12 | pull-requests: write 13 | steps: 14 | - name: 'Auto-assign issue' 15 | uses: pozil/auto-assign-issue@v1 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | assignees: conorheffron 19 | numOfAssignee: 1 20 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/client/Client.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.client; 2 | 3 | import module java.base; 4 | 5 | public interface Client { 6 | 7 | List callGitHubApi(String apiUri, String uri, Class type, String httpMethod); 8 | 9 | HttpsURLConnection createConn(String url, String baseUrl, String httpMethod) throws IOException; 10 | 11 | InputStream readInputStream(HttpsURLConnection conn) throws IOException; 12 | 13 | void closeConn(InputStream inputStream) throws IOException; 14 | } 15 | -------------------------------------------------------------------------------- /src/main/resources/static/ironoc-banner.txt: -------------------------------------------------------------------------------- 1 | ${AnsiColor.GREEN} 2 | _________ _______ _______ _ _______ _______ 3 | \__ __/( ____ )( ___ )( ( /|( ___ )( ____ \ 4 | ) ( | ( )|| ( ) || \ ( || ( ) || ( \/ 5 | | | | (____)|| | | || \ | || | | || | 6 | | | | __)| | | || (\ \) || | | || | 7 | | | | (\ ( | | | || | \ || | | || | 8 | ___) (___| ) \ \__| (___) || ) \ || (___) || (____/\ 9 | \_______/|/ \__/(_______)|/ )_)(_______)(_______/ 10 | ${AnsiColor.DEFAULT} 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "maven" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "robot-logo.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "robot-logo.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/dto/Brew.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.dto; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @Builder 10 | @AllArgsConstructor 11 | @NoArgsConstructor 12 | public class Brew { 13 | 14 | private String title; 15 | 16 | private String description; 17 | 18 | private String[] ingredients; 19 | 20 | private String image; 21 | 22 | private Integer id; 23 | 24 | @Override 25 | public String toString() { 26 | return "title: '" + this.title + "'"; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/components/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Container } from 'reactstrap'; 3 | import '.././App.css'; 4 | import AppNavbar from '.././AppNavbar'; 5 | 6 | function NotFound({ 7 | title = "404 - Page Not Found", 8 | message = "Sorry, the page you are looking for could not be found." 9 | }) { 10 | return ( 11 |
12 | 13 | 14 |
15 |

{title}

16 |

{message}

17 |
18 |
19 |
20 | ); 21 | } 22 | 23 | export default NotFound; 24 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/utils/UrlUtils.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.utils; 2 | 3 | import module java.base; 4 | 5 | import net.ironoc.portfolio.logger.AbstractLogger; 6 | import org.springframework.stereotype.Component; 7 | 8 | @Component 9 | public class UrlUtils extends AbstractLogger { 10 | 11 | public boolean isValidURL(String urlString) { 12 | try { 13 | URL url = URI.create(urlString).toURL(); 14 | url.toURI(); 15 | return true; 16 | } catch (Exception e) { 17 | error("Unexpected exception occurred.", e); 18 | return false; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/dto/OwnerDto.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | import lombok.ToString; 8 | import lombok.Builder; 9 | import lombok.AllArgsConstructor; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | @ToString() 14 | @Builder 15 | @AllArgsConstructor 16 | @JsonIgnoreProperties(ignoreUnknown = true) 17 | public class OwnerDto { 18 | 19 | private int id; 20 | 21 | private String login; 22 | 23 | @JsonProperty("html_url") 24 | private String htmlUrl; 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import reportWebVitals from './reportWebVitals'; 4 | import 'bootstrap/dist/css/bootstrap.min.css'; 5 | import './index.css'; 6 | import App from './App'; 7 | import AppNavBar from './AppNavbar'; 8 | import Footer from './Footer'; 9 | 10 | const root = createRoot(document.getElementById('root')); 11 | root.render( 12 | 13 |
14 | 15 |
16 | 17 |
18 |
19 |
20 |
21 | ); 22 | 23 | reportWebVitals(); 24 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/dto/LabelDto.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import com.fasterxml.jackson.annotation.JsonInclude; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Getter; 8 | import lombok.NoArgsConstructor; 9 | 10 | @Builder 11 | @AllArgsConstructor 12 | @NoArgsConstructor 13 | @Getter 14 | @JsonIgnoreProperties(ignoreUnknown = true) 15 | @JsonInclude(JsonInclude.Include.NON_NULL) 16 | public class LabelDto { 17 | 18 | private String name; 19 | 20 | @Override 21 | public String toString() { 22 | return "name: '" + this.name + "'"; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/components/__tests__/NotFound.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import NotFound from '../NotFound'; 3 | 4 | describe('NotFound', () => { 5 | test('renders AppNavbar component', () => { 6 | render(); 7 | expect(screen.getByRole('banner')).toBeInTheDocument(); 8 | }); 9 | 10 | test('displays 404 error message', () => { 11 | render(); 12 | expect(screen.getByText('404 - Page Not Found')).toBeInTheDocument(); 13 | }); 14 | 15 | test('displays the apology message', () => { 16 | render(); 17 | expect(screen.getByText('Sorry, the page you are looking for could not be found.')).toBeInTheDocument(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/dto/Donate.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.dto; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Data 9 | @Builder 10 | @AllArgsConstructor 11 | @NoArgsConstructor 12 | public class Donate { 13 | 14 | private String donate; 15 | 16 | private String link; 17 | 18 | private String img; 19 | 20 | private String alt; 21 | 22 | private String name; 23 | 24 | private String overview; 25 | 26 | private Integer founded; 27 | 28 | private String phone; 29 | 30 | @Override 31 | public String toString() { 32 | return "name: '" + this.name + "'"; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/logger/AbstractLogger.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.logger; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | 5 | @Slf4j 6 | public abstract class AbstractLogger implements LoggerI { 7 | 8 | @Override 9 | public void info(String message, Object...values) { 10 | log.info(message, values); 11 | } 12 | 13 | @Override 14 | public void debug(String message, Object...values) { 15 | log.debug(message, values); 16 | } 17 | 18 | @Override 19 | public void error(String message, Object...values) { 20 | log.error(message, values); 21 | } 22 | 23 | @Override 24 | public void warn(String message, Object...values) { 25 | log.warn(message, values); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/resources/json/test_repo_detail_response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "bio-cell-red-edge", 4 | "fullName": "conorheffron/bio-cell-red-edge", 5 | "repoUrl": "https://github.com/conorheffron/bio-cell-red-edge", 6 | "description": "Edge Detection of Biological Cell (Image Processing Script)", 7 | "appHome": "https://conorheffron.github.io/bio-cell-red-edge/", 8 | "topics": "[Biology, computer-vision, image-processing, scikitlearn-machine-learning]" 9 | }, 10 | { 11 | "name": "booking-sys", 12 | "repoUrl": "https://github.com/conorheffron/booking-sys", 13 | "appHome": "https://booking-sys-ebgefrdmh3afbhee.northeurope-01.azurewebsites.net/book/", 14 | "description": "python3 and django5 web app", 15 | "topics": "" 16 | } 17 | ] -------------------------------------------------------------------------------- /frontend/src/LoadingSpinner.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Button from 'react-bootstrap/Button'; 3 | import Spinner from 'react-bootstrap/Spinner'; 4 | import { Container } from 'reactstrap'; 5 | 6 | class LoadingSpinner extends Component { 7 | render() { 8 | return ( 9 | 10 | 17 | 18 | ); 19 | } 20 | } 21 | 22 | export default LoadingSpinner; 23 | -------------------------------------------------------------------------------- /src/main/resources/graphql/charities.txt: -------------------------------------------------------------------------------- 1 | The Jack and Jill Children’s Foundation 2 | Vision Ireland, the new name for NCBI 3 | Temple Street Children’s University Hospital 4 | Irish Cancer Society 5 | The Society of Saint Vincent de Paul 6 | Dublin Simon Community 7 | Debra Ireland 8 | Red Cross 9 | Save the Children 10 | UNICEF 11 | Doctors Without Borders 12 | Make-A-Wish Ireland 13 | Dog's Trust Ireland 14 | Marie Keating Foundation 15 | The Irish Heart Foundation 16 | Ronald McDonald House Charities 17 | Féileacáin 18 | My Lovely Horse Animal Rescue 19 | Oesophageal Cancer Fund 20 | Sightsavers 21 | Trócaire 22 | Barnardos Ireland 23 | ALONE 24 | CHI at Crumlin 25 | CHI at Temple Street 26 | CHI at Connolly 27 | CHI at Tallaght 28 | CHI at Blanchardstown 29 | CHI at St James’s 30 | The New Children's Hospital project 31 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/AppTest.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.mockito.MockedStatic; 5 | import org.springframework.boot.SpringApplication; 6 | 7 | import static org.mockito.Mockito.mockStatic; 8 | import static org.mockito.Mockito.times; 9 | 10 | class AppTest { 11 | // mocks 12 | private final MockedStatic springApplicationMockedStatic = mockStatic(SpringApplication.class); 13 | 14 | @Test 15 | void test_run_success() { 16 | // given 17 | String[] args = { "test" }; 18 | 19 | // when 20 | App.main(args); 21 | 22 | // then 23 | springApplicationMockedStatic.verify(() -> SpringApplication.run(App.class, args), 24 | times(1) 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/aws/AwsSecretManagerTest.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.aws; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.junit.jupiter.api.extension.ExtendWith; 5 | import org.mockito.InjectMocks; 6 | import org.mockito.junit.jupiter.MockitoExtension; 7 | 8 | import static org.hamcrest.MatcherAssert.assertThat; 9 | import static org.hamcrest.Matchers.is; 10 | import static org.hamcrest.Matchers.notNullValue; 11 | 12 | @ExtendWith(MockitoExtension.class) 13 | class AwsSecretManagerTest { 14 | 15 | @InjectMocks 16 | private AwsSecretManager awsSecretManager; 17 | 18 | @Test 19 | void test_getGitSecret_success() { 20 | // when 21 | String result = awsSecretManager.getGitSecret(); 22 | 23 | // then 24 | assertThat(result, is(notNullValue())); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/dto/RepositoryIssueDto.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 4 | import com.fasterxml.jackson.annotation.JsonInclude; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Getter; 8 | import lombok.NoArgsConstructor; 9 | 10 | @Builder 11 | @AllArgsConstructor 12 | @NoArgsConstructor 13 | @Getter 14 | @JsonIgnoreProperties(ignoreUnknown = true) 15 | @JsonInclude(JsonInclude.Include.NON_NULL) 16 | public class RepositoryIssueDto { 17 | 18 | private String number; 19 | 20 | private String title; 21 | 22 | private String body; 23 | 24 | private String state; 25 | 26 | private LabelDto[] labels; 27 | 28 | @Override 29 | public String toString() { 30 | return "number: '" + this.number + "'"; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/web/page/DonatePage.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.web.page; 2 | 3 | import lombok.Getter; 4 | import org.openqa.selenium.WebDriver; 5 | import org.openqa.selenium.WebElement; 6 | import org.openqa.selenium.support.FindBy; 7 | import org.openqa.selenium.support.PageFactory; 8 | 9 | @Getter 10 | public class DonatePage { 11 | 12 | protected WebDriver driver; 13 | 14 | @FindBy(css = ".App .navbar-toggler-icon") 15 | private WebElement hamburgerIcon; 16 | 17 | @FindBy(xpath = "//div[2]/div/div/nav/div/div/div/a/img") 18 | private WebElement home; 19 | 20 | public DonatePage(WebDriver driver) { 21 | this.driver = driver; 22 | } 23 | 24 | public HomePage goToHome() { 25 | hamburgerIcon.click(); 26 | home.click(); 27 | return PageFactory.initElements(driver, HomePage.class); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/config/TestIronocConfiguration.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.config; 2 | 3 | import org.springframework.beans.BeansException; 4 | import org.springframework.boot.test.context.TestConfiguration; 5 | import org.springframework.context.ApplicationContext; 6 | import org.springframework.context.ApplicationContextAware; 7 | import org.springframework.context.annotation.ComponentScan; 8 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 9 | 10 | @TestConfiguration 11 | @ComponentScan(basePackages = { "net.ironoc.portfolio" }) 12 | public class TestIronocConfiguration implements WebMvcConfigurer, ApplicationContextAware { 13 | 14 | private ApplicationContext applicationContext; 15 | 16 | @Override 17 | public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { 18 | this.applicationContext = applicationContext; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/config/PropertyKeyI.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.config; 2 | 3 | public interface PropertyKeyI { 4 | 5 | String getGitApiEndpointRepos(); 6 | 7 | String getGitTimeoutConnect(); 8 | 9 | String getGitTimeoutRead(); 10 | 11 | String getGitInstanceFollowRedirects(); 12 | 13 | String getGitFollowRedirects(); 14 | 15 | String getGitApiEndpointIssues(); 16 | 17 | String getStaticConfIgnorePaths(); 18 | 19 | String getStaticConfHandleExt(); 20 | 21 | String getStaticConfResourceHandler(); 22 | 23 | String getStaticConfResourceLoc(); 24 | 25 | String getGitApiEndpointUserIdsCache(); 26 | 27 | String getGitApiEndpointProjectsCache(); 28 | 29 | String isCacheJobEnabled(); 30 | 31 | String getBrewApiEndpointHot(); 32 | 33 | String getBrewApiEndpointIce(); 34 | 35 | String getBrewGraphEndpoint(); 36 | 37 | String isBrewCacheJobEnabled(); 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/service/GitDetails.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import net.ironoc.portfolio.domain.RepositoryDetailDomain; 6 | import net.ironoc.portfolio.domain.RepositoryIssueDomain; 7 | import net.ironoc.portfolio.dto.RepositoryDetailDto; 8 | import net.ironoc.portfolio.dto.RepositoryIssueDto; 9 | 10 | public interface GitDetails { 11 | 12 | List getRepoDetails(String username, boolean isJob); 13 | 14 | List mapRepositoriesToResponse( 15 | List repositoryDetailDtos); 16 | 17 | List mapResponseToRepositories( 18 | List repositoryDetailDomains); 19 | 20 | List getIssues(String userId, String repo, boolean isJob); 21 | 22 | List mapIssuesToResponse(List repositoryIssueDtos); 23 | } 24 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/web/page/PortfolioPage.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.web.page; 2 | 3 | import lombok.Getter; 4 | import org.openqa.selenium.WebDriver; 5 | import org.openqa.selenium.WebElement; 6 | import org.openqa.selenium.support.FindBy; 7 | import org.openqa.selenium.support.PageFactory; 8 | 9 | @Getter 10 | public class PortfolioPage { 11 | protected WebDriver driver; 12 | 13 | @FindBy(linkText = "iRonoc") 14 | private WebElement iRonoc; 15 | 16 | @FindBy(linkText = "Brews") 17 | private WebElement brews; 18 | 19 | @FindBy(css = ".content-inner .navbar-toggler-icon") 20 | private WebElement hamburgerIcon; 21 | 22 | public PortfolioPage(WebDriver driver) { 23 | this.driver = driver; 24 | } 25 | 26 | public BrewsPage goToBrews() { 27 | hamburgerIcon.click(); 28 | iRonoc.click(); 29 | brews.click(); 30 | return PageFactory.initElements(driver, BrewsPage.class); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/web/page/AboutPage.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.web.page; 2 | 3 | import lombok.Getter; 4 | import org.openqa.selenium.WebDriver; 5 | import org.openqa.selenium.WebElement; 6 | import org.openqa.selenium.support.FindBy; 7 | import org.openqa.selenium.support.PageFactory; 8 | 9 | @Getter 10 | public class AboutPage { 11 | 12 | protected WebDriver driver; 13 | 14 | @FindBy(linkText = "iRonoc") 15 | private WebElement iRonoc; 16 | 17 | @FindBy(linkText = "Portfolio") 18 | private WebElement portfolio; 19 | 20 | @FindBy(css = ".App .navbar-toggler-icon") 21 | private WebElement hamburgerIcon; 22 | 23 | public AboutPage(WebDriver driver) { 24 | this.driver = driver; 25 | } 26 | 27 | public PortfolioPage goToPortfolio() { 28 | hamburgerIcon.click(); 29 | iRonoc.click(); 30 | portfolio.click(); 31 | return PageFactory.initElements(driver, PortfolioPage.class); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/utils/UrlUtilsTest.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.utils; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.junit.jupiter.api.extension.ExtendWith; 5 | import org.mockito.InjectMocks; 6 | import org.mockito.junit.jupiter.MockitoExtension; 7 | 8 | import static org.hamcrest.MatcherAssert.assertThat; 9 | import static org.hamcrest.Matchers.is; 10 | 11 | @ExtendWith(MockitoExtension.class) 12 | class UrlUtilsTest { 13 | 14 | @InjectMocks 15 | private UrlUtils urlUtils; 16 | 17 | @Test 18 | void test_isValidURL_false() { 19 | // when 20 | boolean result = urlUtils.isValidURL("test_url"); 21 | 22 | // then 23 | assertThat(result, is(false)); 24 | } 25 | 26 | @Test 27 | void test_isValidURL_true() { 28 | // when 29 | boolean result = urlUtils.isValidURL("https://api.github.com/users/conorheffron/repos"); 30 | 31 | // then 32 | assertThat(result, is(true)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/components/__tests__/About.test.js: -------------------------------------------------------------------------------- 1 | import { screen, render } from '@testing-library/react'; 2 | import About from '../About'; 3 | 4 | describe('About Component', () => { 5 | test('renders with default props', () => { 6 | render(); 7 | 8 | expect(screen.getByAltText("View Conor Heffron's profile on LinkedIn")).toBeInTheDocument(); 9 | expect(screen.getByAltText('Strava')).toBeInTheDocument(); 10 | }); 11 | 12 | test('renders with provided props', () => { 13 | const props = { 14 | link: 'https://example.com', 15 | imgSrc: 'https://example.com/image.png', 16 | imgAlt: 'Example Image', 17 | stravaLink: 'https://example.com/strava', 18 | stravaImgSrc: 'https://example.com/strava-image.png', 19 | stravaImgAlt: 'Example Strava', 20 | }; 21 | render(); 22 | expect(screen.getByAltText('Example Image')).toBeInTheDocument(); 23 | expect(screen.getByAltText('Example Strava')).toBeInTheDocument(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/config/PropertyConfigI.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.config; 2 | 3 | import module java.base; 4 | 5 | public interface PropertyConfigI { 6 | 7 | String getGitApiEndpointRepos(); 8 | 9 | Integer getGitTimeoutConnect(); 10 | 11 | Integer getGitTimeoutRead(); 12 | 13 | Boolean getGitInstanceFollowRedirects(); 14 | 15 | Boolean getGitFollowRedirects(); 16 | 17 | String getGitApiEndpointIssues(); 18 | 19 | String getStaticConfIgnorePaths(); 20 | 21 | List getStaticConfHandleExt(); 22 | 23 | String getStaticConfResourceHandler(); 24 | 25 | String getStaticConfResourceLoc(); 26 | 27 | List getGitApiEndpointUserIdsCache(); 28 | 29 | List getGitApiEndpointProjectsCache(); 30 | 31 | boolean isCacheJobEnabled(); 32 | 33 | String getBrewApiEndpointHot(); 34 | 35 | String getBrewApiEndpointIce(); 36 | 37 | String getBrewGraphEndpoint(); 38 | 39 | boolean isBrewsCacheJobEnabled(); 40 | } 41 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | iRonoc React App | Portfolio | Software Engineer | Data Analytics 15 | | Conor Heffron | Web Application Development | Software Engineering 16 | | Data Engineering | Cloud Deployments | DevOps 17 | 18 | 19 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Java CI with Maven 10 | 11 | permissions: 12 | contents: read 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | branches: [ "main" ] 19 | 20 | jobs: 21 | build: 22 | 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v3 27 | - name: Set up JDK 25 28 | uses: actions/setup-java@v3 29 | with: 30 | java-version: '25' 31 | distribution: 'temurin' 32 | cache: maven 33 | - name: Build with Maven 34 | run: ./mvnw -B package --file pom.xml 35 | -------------------------------------------------------------------------------- /src/test/resources/graphql/iRonocQuery.graphqls.SAMPLE: -------------------------------------------------------------------------------- 1 | query brews { 2 | brews { 3 | description 4 | id 5 | image 6 | ingredients 7 | title 8 | } 9 | } 10 | 11 | query donateItems { 12 | donateItems { 13 | donate 14 | link 15 | img 16 | alt 17 | name 18 | overview 19 | founded 20 | phone 21 | } 22 | } 23 | 24 | query charityOptionByFounded1 { 25 | charityOptionByFounded(founded: 1931) { 26 | donate 27 | link 28 | img 29 | alt 30 | name 31 | overview 32 | founded 33 | phone 34 | } 35 | } 36 | 37 | query charityOptionByFounded2 { 38 | charityOptionByFounded(founded: 1872) { 39 | donate 40 | link 41 | img 42 | alt 43 | name 44 | overview 45 | founded 46 | phone 47 | } 48 | } 49 | 50 | query charityOptionByDonateLink { 51 | charityOptionByDonateLink( 52 | link: "https://www.jackandjill.ie/professionals/ways-to-donate/" 53 | ) { 54 | donate 55 | founded 56 | link 57 | img 58 | alt 59 | name 60 | overview 61 | phone 62 | } 63 | } -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/service/CoffeeCacheService.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import jakarta.annotation.PreDestroy; 6 | import net.ironoc.portfolio.domain.CoffeeDomain; 7 | import org.springframework.stereotype.Service; 8 | 9 | @Service 10 | public class CoffeeCacheService implements CoffeesCache { 11 | 12 | private static final String BREWS_KEY = "brews-key"; 13 | private final Map> coffees; 14 | 15 | public CoffeeCacheService() { 16 | this.coffees = new HashMap<>(); 17 | } 18 | 19 | @Override 20 | public void put(List repositoryIssueDomains) { 21 | coffees.put(BREWS_KEY, repositoryIssueDomains); 22 | } 23 | 24 | @Override 25 | public List get() { 26 | return coffees.get(BREWS_KEY); 27 | } 28 | 29 | @Override 30 | public void remove() { 31 | coffees.remove(BREWS_KEY); 32 | } 33 | 34 | @PreDestroy 35 | public void tearDown() { 36 | coffees.clear(); 37 | } 38 | } 39 | 40 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/domain/IngredientsDeserializer.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.domain; 2 | 3 | import module java.base; 4 | 5 | import com.fasterxml.jackson.core.JsonParser; 6 | import com.fasterxml.jackson.databind.DeserializationContext; 7 | import com.fasterxml.jackson.databind.JsonDeserializer; 8 | import com.fasterxml.jackson.databind.JsonNode; 9 | import net.ironoc.portfolio.exception.IronocJsonException; 10 | 11 | public class IngredientsDeserializer extends JsonDeserializer> { 12 | 13 | @Override 14 | public List deserialize(JsonParser p, DeserializationContext context) throws IOException { 15 | JsonNode node = p.getCodec().readTree(p); 16 | List ingredients = new ArrayList<>(); 17 | 18 | if (node.isArray()) { 19 | for (JsonNode element : node) { 20 | ingredients.add(element.asText()); 21 | } 22 | } else { 23 | throw new IronocJsonException("Unexpected exception occurred deserializing ingredients"); 24 | } 25 | return ingredients; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/service/GitRepoCacheService.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import net.ironoc.portfolio.domain.RepositoryDetailDomain; 6 | import jakarta.annotation.PreDestroy; 7 | import org.springframework.stereotype.Service; 8 | 9 | @Service 10 | public final class GitRepoCacheService extends AbstractGitCache implements GitRepoCache { 11 | 12 | private final Map> userGitDetails; 13 | 14 | public GitRepoCacheService() { 15 | this.userGitDetails = new HashMap<>(); 16 | } 17 | 18 | @Override 19 | public void put(String userId, List repositoryDetails) { 20 | userGitDetails.put(userId, repositoryDetails); 21 | } 22 | 23 | @Override 24 | public List get(String userId) { 25 | return userGitDetails.get(userId); 26 | } 27 | 28 | @Override 29 | public void remove(String key) { 30 | userGitDetails.remove(key); 31 | } 32 | 33 | @PreDestroy 34 | public void tearDown() { 35 | this.clear(userGitDetails); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/config/GraphiQlConfiguration.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.core.annotation.Order; 6 | import org.springframework.core.io.ClassPathResource; 7 | import org.springframework.graphql.server.webmvc.GraphiQlHandler; 8 | import org.springframework.web.servlet.function.RouterFunction; 9 | import org.springframework.web.servlet.function.RouterFunctions; 10 | import org.springframework.web.servlet.function.ServerResponse; 11 | 12 | @Configuration 13 | public class GraphiQlConfiguration { 14 | 15 | @Bean 16 | @Order(0) 17 | public RouterFunction graphiQlRouterFunction() { 18 | RouterFunctions.Builder builder = RouterFunctions.route(); 19 | ClassPathResource graphiQlPage = new ClassPathResource("graphiql/index.html"); 20 | GraphiQlHandler graphiQLHandler = new GraphiQlHandler("/graphql", "", graphiQlPage); 21 | builder = builder.GET("/graphiql", graphiQLHandler::handleRequest); 22 | return builder.build(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/service/PortfolioItemsResolver.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import com.fasterxml.jackson.core.type.TypeReference; 6 | import com.fasterxml.jackson.databind.ObjectMapper; 7 | import net.ironoc.portfolio.logger.AbstractLogger; 8 | import org.springframework.core.io.ClassPathResource; 9 | import org.springframework.stereotype.Component; 10 | 11 | @Component 12 | public class PortfolioItemsResolver extends AbstractLogger { 13 | 14 | protected static final String PORTFOLIO_ITEMS_JSON_FILE = "json/portfolio-items.json"; 15 | 16 | public List> getPortfolioItems() { 17 | ObjectMapper objectMapper = new ObjectMapper(); 18 | try { 19 | // Load the JSON file from resources 20 | return objectMapper.readValue( 21 | new ClassPathResource(PORTFOLIO_ITEMS_JSON_FILE).getInputStream(), 22 | new TypeReference<>() {}); 23 | } catch (IOException e) { 24 | error("Failed to load Portfolio items JSON", e); 25 | } 26 | return Collections.emptyList(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/maven-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a package using Maven and then publish it to GitHub packages when a release is created 2 | # For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#apache-maven-with-a-settings-path 3 | 4 | name: Maven Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up JDK 25 21 | uses: actions/setup-java@v4 22 | with: 23 | java-version: '25' 24 | distribution: 'temurin' 25 | server-id: github # Value of the distributionManagement/repository/id field of the pom.xml 26 | settings-path: ${{ github.workspace }} # location for the settings.xml file 27 | 28 | - name: Build with Maven 29 | run: ./mvnw -B -DskipTests=true package --file pom.xml 30 | 31 | - name: Publish to GitHub Packages Apache Maven 32 | run: ./mvnw deploy -s $GITHUB_WORKSPACE/settings.xml 33 | env: 34 | GITHUB_TOKEN: ${{ github.token }} 35 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/graph/BrewsResolver.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.graph; 2 | 3 | import module java.base; 4 | 5 | import com.fasterxml.jackson.core.type.TypeReference; 6 | import com.fasterxml.jackson.databind.ObjectMapper; 7 | import graphql.kickstart.tools.GraphQLQueryResolver; 8 | import net.ironoc.portfolio.logger.AbstractLogger; 9 | import org.springframework.core.io.ClassPathResource; 10 | import org.springframework.stereotype.Component; 11 | 12 | @Component 13 | public class BrewsResolver extends AbstractLogger implements GraphQLQueryResolver { 14 | 15 | protected static final String BREWS_JSON_FILE = "json/brews.json"; 16 | 17 | public List> getBrews() { 18 | ObjectMapper objectMapper = new ObjectMapper(); 19 | try { 20 | // Load the JSON file from resources 21 | return objectMapper.readValue( 22 | new ClassPathResource(BREWS_JSON_FILE).getInputStream(), 23 | new TypeReference<>() {}); 24 | } catch (IOException e) { 25 | error("Failed to load Brews JSON", e); 26 | } 27 | return Collections.emptyList(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/resources/graphql/ironocGraphSchema.graphql: -------------------------------------------------------------------------------- 1 | type DonateItem { 2 | donate: String! 3 | link: String! 4 | img: String! 5 | alt: String! 6 | name: String! 7 | overview: String! 8 | founded: Int! 9 | phone: String! 10 | } 11 | 12 | input DonateItemOrder { 13 | name: SortingOrder 14 | founded: SortingOrder 15 | } 16 | 17 | enum SortingOrder { 18 | ASC 19 | DESC 20 | } 21 | 22 | type Brew { 23 | title: String! 24 | description: String 25 | ingredients: [String!]! 26 | image: String! 27 | id: Int! 28 | } 29 | 30 | type Query { 31 | brews: [Brew]! 32 | donateItems(orderBy: DonateItemOrder): [DonateItem]! 33 | donateItemsByCountAndOffset(count: Int, offset: Int): [DonateItem]! 34 | charityOptionByFounded(founded: Int): DonateItem! 35 | charityOptionByDonateLink(link: String): DonateItem! 36 | charityOptionByName(name: String): DonateItem! 37 | } 38 | 39 | type Mutation { 40 | addCharityOption( 41 | donate: String! 42 | link: String! 43 | img: String! 44 | alt: String! 45 | name: String! 46 | overview: String! 47 | founded: Int! 48 | phone: String! 49 | ): DonateItem! 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/service/GitProjectCacheService.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import jakarta.annotation.PreDestroy; 6 | import net.ironoc.portfolio.domain.RepositoryIssueDomain; 7 | import org.springframework.stereotype.Service; 8 | 9 | @Service 10 | public final class GitProjectCacheService extends AbstractGitCache implements GitProjectCache { 11 | 12 | private final Map> projectGitDetails; 13 | 14 | public GitProjectCacheService() { 15 | this.projectGitDetails = new HashMap<>(); 16 | } 17 | 18 | @Override 19 | public void put(String userId, String project, List repositoryIssueDomains) { 20 | projectGitDetails.put(userId + project, repositoryIssueDomains); 21 | } 22 | 23 | @Override 24 | public List get(String userId, String project) { 25 | return projectGitDetails.get(userId + project); 26 | } 27 | 28 | @Override 29 | public void remove(String key) { 30 | projectGitDetails.remove(key); 31 | } 32 | 33 | @PreDestroy 34 | public void tearDown() { 35 | this.clear(projectGitDetails); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/Footer.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Container } from 'reactstrap'; 3 | 4 | class Footer extends Component { 5 | 6 | constructor(props) { 7 | super(props); 8 | this.state = { 9 | version: '', 10 | error: null, 11 | }; 12 | } 13 | 14 | componentDidMount() { 15 | fetch('/api/application/version') 16 | .then(response => { 17 | if (!response.ok) { 18 | throw new Error('Network response was not ok'); 19 | } 20 | return response.text(); 21 | }) 22 | .then(data => this.setState({ version: data })) 23 | .catch(error => this.setState({ error })); 24 | } 25 | 26 | render() { 27 | const { version, error } = this.state; 28 | 29 | return ( 30 |
31 | 32 | 35 | 36 |
37 | ); 38 | } 39 | } 40 | 41 | export default Footer; 42 | -------------------------------------------------------------------------------- /.github/workflows/sonar.yml: -------------------------------------------------------------------------------- 1 | name: SonarQube 2 | permissions: 3 | contents: read 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | types: [opened, synchronize, reopened] 10 | jobs: 11 | build: 12 | name: Build and analyze 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 18 | - name: Set up JDK 25 19 | uses: actions/setup-java@v4 20 | with: 21 | java-version: 25 22 | distribution: 'zulu' # Alternative distribution options are available. 23 | - name: Cache SonarQube packages 24 | uses: actions/cache@v4 25 | with: 26 | path: ~/.sonar/cache 27 | key: ${{ runner.os }}-sonar 28 | restore-keys: ${{ runner.os }}-sonar 29 | - name: Cache Maven packages 30 | uses: actions/cache@v4 31 | with: 32 | path: ~/.m2 33 | key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} 34 | restore-keys: ${{ runner.os }}-m2 35 | - name: Build and analyze 36 | env: 37 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 38 | run: ./mvnw -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=conorheffron_ironoc 39 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/controller/VersionControllerTest.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.controller; 2 | 3 | import module java.base; 4 | 5 | import org.junit.jupiter.api.Test; 6 | import org.junit.jupiter.api.extension.ExtendWith; 7 | import org.mockito.InjectMocks; 8 | import org.mockito.Mock; 9 | import org.mockito.junit.jupiter.MockitoExtension; 10 | import org.springframework.boot.info.BuildProperties; 11 | 12 | import static org.hamcrest.MatcherAssert.assertThat; 13 | import static org.hamcrest.Matchers.is; 14 | import static org.mockito.Mockito.when; 15 | import static org.mockito.Mockito.verify; 16 | 17 | @ExtendWith(MockitoExtension.class) 18 | public class VersionControllerTest { 19 | 20 | private static final String TEST_VERSION = "2.2-RELEASE"; 21 | 22 | @Mock 23 | private BuildProperties buildPropertiesMock; 24 | 25 | @InjectMocks 26 | private VersionController versionController; 27 | 28 | @Test 29 | public void test_getApplicationVersion_success() { 30 | // given 31 | when(buildPropertiesMock.getVersion()).thenReturn(TEST_VERSION); 32 | 33 | // when 34 | String response = versionController.getApplicationVersion(); 35 | 36 | // then 37 | verify(buildPropertiesMock).getVersion(); 38 | 39 | assertThat(response, is("Version: " + TEST_VERSION)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/web/page/HomePage.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.web.page; 2 | 3 | import lombok.Getter; 4 | import org.openqa.selenium.WebDriver; 5 | import org.openqa.selenium.WebElement; 6 | import org.openqa.selenium.support.FindBy; 7 | import org.openqa.selenium.support.PageFactory; 8 | 9 | @Getter 10 | public class HomePage { 11 | 12 | protected WebDriver driver; 13 | 14 | @FindBy(css = ".App .dropdown:nth-child(5) > .dropdown-toggle") 15 | private WebElement charityOptions; 16 | 17 | @FindBy(linkText = "Donate") 18 | private WebElement donate; 19 | 20 | @FindBy(linkText = "iRonoc") 21 | private WebElement iRonoc; 22 | 23 | @FindBy(linkText = "About") 24 | private WebElement about; 25 | 26 | @FindBy(css = ".App .navbar-toggler-icon") 27 | private WebElement hamburgerIcon; 28 | 29 | public HomePage(WebDriver driver) { 30 | this.driver = driver; 31 | } 32 | 33 | public DonatePage goToDonate() { 34 | hamburgerIcon.click(); 35 | charityOptions.click(); 36 | donate.click(); 37 | return PageFactory.initElements(driver, DonatePage.class); 38 | } 39 | 40 | public AboutPage goToAbout() { 41 | hamburgerIcon.click(); 42 | iRonoc.click(); 43 | about.click(); 44 | return PageFactory.initElements(driver, AboutPage.class); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | permissions: 7 | contents: read 8 | 9 | on: 10 | push: 11 | branches: [ "main" ] 12 | pull_request: 13 | branches: [ "main" ] 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | strategy: 21 | matrix: 22 | node-version: [24.x] 23 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Use Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | cache: 'npm' 32 | cache-dependency-path: './frontend/package-lock.json' 33 | - run: npm cache clean --force 34 | working-directory: './frontend' 35 | - run: npm install 36 | working-directory: './frontend' 37 | - run: npm ci 38 | working-directory: './frontend' 39 | - run: npm run build --if-present 40 | working-directory: './frontend' 41 | - run: npm run test:coverage 42 | working-directory: './frontend' 43 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/domain/RepositoryIssueDomain.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.domain; 2 | 3 | import module java.base; 4 | 5 | import io.swagger.v3.oas.annotations.media.Schema; 6 | import lombok.AllArgsConstructor; 7 | import lombok.Builder; 8 | import lombok.Getter; 9 | 10 | @Builder 11 | @AllArgsConstructor 12 | @Getter 13 | public class RepositoryIssueDomain { 14 | 15 | @Schema(name= "number", description = "Project Issue Number.", 16 | example = "45", requiredMode = Schema.RequiredMode.REQUIRED) 17 | private String number; 18 | 19 | @Schema(name= "title", description = "Issue Title Text.", 20 | example = "The UI is not rendering the GitHub Repo Details View", requiredMode = Schema.RequiredMode.REQUIRED) 21 | private String title; 22 | 23 | @Schema(name= "body", description = "Issue Content & Description.", 24 | example = "The app crashes when I visit the Repo Details View", requiredMode = Schema.RequiredMode.NOT_REQUIRED) 25 | private String body; 26 | 27 | @Schema(name= "state", description = "Issue State.", 28 | example = "Open / Closed etc.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) 29 | private String state; 30 | 31 | @Schema(name= "labels", description = "Issue Labels / Tags.", 32 | example = "bug, java, ui etc.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) 33 | private List labels; 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/dto/RepositoryDetailDto.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.dto; 2 | 3 | import module java.base; 4 | 5 | import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 6 | import com.fasterxml.jackson.annotation.JsonInclude; 7 | import com.fasterxml.jackson.annotation.JsonInclude.Include; 8 | import com.fasterxml.jackson.annotation.JsonProperty; 9 | import lombok.Builder; 10 | import lombok.AllArgsConstructor; 11 | import lombok.NoArgsConstructor; 12 | import lombok.Getter; 13 | 14 | @Builder 15 | @AllArgsConstructor 16 | @NoArgsConstructor 17 | @Getter 18 | @JsonIgnoreProperties(ignoreUnknown = true) 19 | @JsonInclude(Include.NON_NULL) 20 | public class RepositoryDetailDto { 21 | 22 | private int id; 23 | 24 | private String name; 25 | 26 | @JsonProperty("full_name") 27 | private String fullName; 28 | 29 | @JsonProperty("private") 30 | private boolean isPrivate; 31 | 32 | @JsonProperty("owner") 33 | private OwnerDto owner; 34 | 35 | @JsonProperty("html_url") 36 | private String htmlUrl; 37 | 38 | private String description; 39 | 40 | private String language; 41 | 42 | @JsonProperty("git_url") 43 | private String gitUrl; 44 | 45 | @JsonProperty("homepage") 46 | private String homePage; 47 | 48 | private List topics; 49 | 50 | @Override 51 | public String toString() { 52 | return "name: '" + this.name + "'"; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/components/__tests__/CoffeeHome.test.js: -------------------------------------------------------------------------------- 1 | import { screen, render, waitFor } from '@testing-library/react'; 2 | import axios from 'axios'; 3 | import App from '../../App'; 4 | import CoffeeHome from '../CoffeeHome'; 5 | 6 | // Mocking axios 7 | jest.mock('axios'); 8 | 9 | // Sample data for testing 10 | const coffeeItems = [ 11 | { 12 | title: 'Espresso', 13 | ingredients: ['Water', 'Coffee beans'], 14 | image: 'https://example.com/espresso.jpg', 15 | }, 16 | { 17 | title: 'Cappuccino', 18 | ingredients: ['Espresso', 'Steamed milk', 'Foam milk'], 19 | image: 'https://example.com/cappuccino.jpg', 20 | }, 21 | ]; 22 | 23 | describe('CoffeeHome', () => { 24 | beforeEach(() => { 25 | axios.get.mockResolvedValueOnce({ data: coffeeItems }); 26 | }); 27 | 28 | test('renders AppNavbar component', () => { 29 | render(); 30 | expect(screen.getByRole('banner')).toBeInTheDocument(); 31 | }); 32 | 33 | test('displays loading state initially', () => { 34 | render(); 35 | expect(screen.getByText('Loading...')).toBeInTheDocument(); 36 | }); 37 | 38 | test('renders CoffeeCarousel component with coffee items', async () => { 39 | render(); 40 | 41 | // Wait for the coffee items to be fetched and rendered 42 | await waitFor(() => { 43 | expect(screen.getByText('Espresso')).toBeInTheDocument(); 44 | expect(screen.getByText('Cappuccino')).toBeInTheDocument(); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /frontend/src/components/CoffeeHome.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Container } from 'reactstrap'; 3 | import axios from 'axios'; 4 | import AppNavbar from '.././AppNavbar'; 5 | import CoffeeCarousel from './CoffeeCarousel'; 6 | import LoadingSpinner from '.././LoadingSpinner'; 7 | 8 | class CoffeeHome extends Component { 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | coffeeItems: [] 13 | }; 14 | } 15 | 16 | componentDidMount() { 17 | axios.get('/api/coffees-graph-ql') 18 | .then(response => this.setState({ coffeeItems: response.data })) 19 | .catch(error => console.error('Error fetching coffee details:', error)); 20 | } 21 | 22 | render() { 23 | const { coffeeItems } = this.state; 24 | 25 | return ( 26 |
27 | 28 | 29 | {coffeeItems.length > 0 ? ( 30 | <> 31 |

32 | 33 | 34 | ) : ( 35 | <> 36 |


37 | 38 | 39 | )} 40 |
41 |
42 | ); 43 | } 44 | } 45 | 46 | export default CoffeeHome; 47 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/controller/VersionController.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.controller; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 5 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.info.BuildProperties; 8 | import org.springframework.http.MediaType; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.RequestMapping; 11 | import org.springframework.web.bind.annotation.RestController; 12 | 13 | @RestController 14 | @RequestMapping("/api") 15 | public class VersionController { 16 | 17 | private final BuildProperties buildProperties; 18 | 19 | public VersionController(@Autowired BuildProperties buildProperties) { 20 | this.buildProperties = buildProperties; 21 | } 22 | 23 | @Operation(summary = "Get ironoc Application Version", 24 | description = "Returns a string value that represents the version of the ironoc application currently running") 25 | @ApiResponses(value = { 26 | @ApiResponse(responseCode = "200", 27 | description = "Successfully retrieved ironoc application version.") 28 | }) 29 | @GetMapping(value = {"/application/version"}, produces= MediaType.TEXT_PLAIN_VALUE) 30 | public String getApplicationVersion() { 31 | return "Version: " + this.buildProperties.getVersion(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/controller/BaseControllerIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.controller; 2 | 3 | import net.ironoc.portfolio.SeleniumConfig; 4 | import net.ironoc.portfolio.config.TestIronocConfiguration; 5 | import org.junit.jupiter.api.TestInstance; 6 | import org.springframework.test.context.ContextConfiguration; 7 | import org.springframework.test.context.TestPropertySource; 8 | 9 | @ContextConfiguration(classes = {TestIronocConfiguration.class, SeleniumConfig.class}) 10 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 11 | @TestPropertySource(properties = { 12 | "net.ironoc.portfolio.config.ignore-paths=api", 13 | "net.ironoc.portfolio.config.handle-extensions=css,html", 14 | "net.ironoc.portfolio.config.resource-handler=/**", 15 | "net.ironoc.portfolio.config.resource-loc=\"classpath:/static/\"", 16 | "net.ironoc.portfolio.github.api.endpoint.user-ids-cache=conorheffron", 17 | "net.ironoc.portfolio.github.api.endpoint.projects-cache=\"ironoc,ironoc-db,booking-sys\"", 18 | "net.ironoc.portfolio.github.cron-job=0 1 1 ? * *", 19 | "net.ironoc.portfolio.github.job-enable=false", 20 | "net.ironoc.portfolio.brew.cron-job=0 0 2 ? * *", 21 | "net.ironoc.portfolio.brew.job-enable=false", 22 | "net.ironoc.portfolio.brew.api.endpoint.ice=https://api.sampleapis.com/coffee/iced", 23 | "net.ironoc.portfolio.brew.api.endpoint.hot=https://api.sampleapis.com/coffee/hot", 24 | "net.ironoc.portfolio.github.api.endpoint.repos=ironoc-db" 25 | }) 26 | public class BaseControllerIntegrationTest { 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/config/Properties.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.config; 2 | 3 | import lombok.Getter; 4 | 5 | @Getter 6 | public enum Properties { 7 | 8 | STATIC_CONF_IGNORE_PATHS("net.ironoc.portfolio.config.ignore-paths"), 9 | STATIC_CONF_HANDLE_EXT("net.ironoc.portfolio.config.handle-extensions"), 10 | STATIC_CONF_RESOURCE_HANDLER("net.ironoc.portfolio.config.resource-handler"), 11 | STATIC_CONF_RESOURCE_LOC("net.ironoc.portfolio.config.resource-loc"), 12 | IS_GITHUB_JOB_ENABLED("net.ironoc.portfolio.github.job-enable"), 13 | IS_BREWS_JOB_ENABLED("net.ironoc.portfolio.brew.job-enable"), 14 | BREWS_GRAPH_ENDPOINT("net.ironoc.portfolio.brew.graph.endpoint"), 15 | BREWS_API_ENDPOINT_HOT("net.ironoc.portfolio.brew.api.endpoint.hot"), 16 | BREWS_API_ENDPOINT_ICE("net.ironoc.portfolio.brew.api.endpoint.ice"), 17 | GIT_API_ENDPOINT_REPOS("net.ironoc.portfolio.github.api.endpoint.repos"), 18 | GIT_API_ENDPOINT_REPOS_PARAM_CACHE("net.ironoc.portfolio.github.api.endpoint.user-ids-cache"), 19 | GIT_API_ENDPOINT_ISSUES("net.ironoc.portfolio.github.api.endpoint.issues"), 20 | GIT_API_ENDPOINT_ISSUES_PARAM_CACHE("net.ironoc.portfolio.github.api.endpoint.projects-cache"), 21 | GIT_TIMEOUT_CONNECT ("net.ironoc.portfolio.github.timeout.connect"), 22 | GIT_TIMEOUT_READ("net.ironoc.portfolio.github.timeout.read"), 23 | GIT_INSTANCE_FOLLOW_REDIRECTS("net.ironoc.portfolio.github.instance-follow-redirects"), 24 | GIT_FOLLOW_REDIRECTS("net.ironoc.portfolio.github.follow-redirects"); 25 | 26 | private final String key; 27 | 28 | Properties(String key) { 29 | this.key = key; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/SeleniumConfig.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio; 2 | 3 | import org.openqa.selenium.WebDriver; 4 | import org.openqa.selenium.firefox.FirefoxDriver; 5 | import org.openqa.selenium.firefox.FirefoxOptions; 6 | import org.springframework.boot.test.context.TestConfiguration; 7 | import org.springframework.context.annotation.Bean; 8 | 9 | @TestConfiguration 10 | public class SeleniumConfig { 11 | 12 | @Bean 13 | public WebDriver webDriver() { 14 | // Use Chrome Pop-Up to run locally 15 | // System.setProperty("webdriver.chrome.driver", "/usr/local/bin/chromedriver"); 16 | // WebDriver webDriver = new ChromeDriver(); 17 | // return webDriver; 18 | 19 | // Run selenium web driver as background process with headless chrome 20 | // ChromeOptions options = new ChromeOptions(); 21 | // options.addArguments("--headless=new"); // Use new headless mode 22 | // options.addArguments("--disable-gpu"); // Recommended for headless execution 23 | // options.addArguments("--window-size=1920,1080"); // Set specific window size 24 | // WebDriver webDriver = new ChromeDriver(options); 25 | // return webDriver; 26 | 27 | // Run selenium web driver as background process with headless FireFox 28 | FirefoxOptions options = new FirefoxOptions(); 29 | options.addArguments("--headless"); // Enable headless mode 30 | options.addArguments("--disable-gpu"); // Recommended for headless execution 31 | options.addArguments("--window-size=1920,1080"); // Set specific window size 32 | WebDriver webDriver = new FirefoxDriver(options); 33 | return webDriver; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/domain/CoffeeDomain.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.domain; 2 | 3 | import module java.base; 4 | 5 | import com.fasterxml.jackson.databind.annotation.JsonDeserialize; 6 | import io.swagger.v3.oas.annotations.media.Schema; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Builder; 9 | import lombok.Getter; 10 | import lombok.NoArgsConstructor; 11 | 12 | @Builder 13 | @AllArgsConstructor 14 | @NoArgsConstructor 15 | @Getter 16 | public class CoffeeDomain { 17 | 18 | @Schema(name= "title", description = "Coffee Name/Type.", example = "Cold Brew", 19 | requiredMode = Schema.RequiredMode.REQUIRED) 20 | private String title; 21 | 22 | @Schema(name= "description", description = "Drink Description.", example = "The trendiest of the iced coffee bunch", 23 | requiredMode = Schema.RequiredMode.NOT_REQUIRED) 24 | private String description; 25 | 26 | @Schema(name= "ingredients", description = "Main Ingredients.", example = "Long steeped coffee, Ice", 27 | requiredMode = Schema.RequiredMode.REQUIRED) 28 | @JsonDeserialize(using = IngredientsDeserializer.class) 29 | private List ingredients; 30 | 31 | @Schema(name= "image", description = "Image URL.", 32 | example = "https://upload.wikimedia.org/640px-ColdBrewCoffeein_Cans.png", 33 | requiredMode = Schema.RequiredMode.REQUIRED) 34 | private String image; 35 | 36 | @Schema(name= "id", description = "ID of Coffee Details Object.", example = "3", 37 | requiredMode = Schema.RequiredMode.AUTO) 38 | private Integer id; 39 | 40 | @Override 41 | public String toString() { 42 | return "name: '" + this.title + "'" + " id: '" + this.id + "'"; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.aws/task-definition.json: -------------------------------------------------------------------------------- 1 | { 2 | "containerDefinitions": [ 3 | { 4 | "name": "ironoc", 5 | "image": "345594590074.dkr.ecr.eu-north-1.amazonaws.com/conorheffron/ironoc:latest", 6 | "memoryReservation": "512", 7 | "portMappings": [ 8 | { 9 | "name": "ironoc-80-tcp", 10 | "containerPort": 8080, 11 | "hostPort": 80, 12 | "protocol": "tcp", 13 | "appProtocol": "http" 14 | } 15 | ], 16 | "essential": true, 17 | "environment": [], 18 | "mountPoints": [], 19 | "volumesFrom": [], 20 | "logConfiguration": { 21 | "logDriver": "awslogs", 22 | "options": { 23 | "awslogs-group": "/ecs/ironoc", 24 | "mode": "non-blocking", 25 | "awslogs-create-group": "true", 26 | "max-buffer-size": "25m", 27 | "awslogs-region": "eu-north-1", 28 | "awslogs-stream-prefix": "ecs" 29 | } 30 | }, 31 | "systemControls": [] 32 | } 33 | ], 34 | "family": "ironoc", 35 | "taskRoleArn": "arn:aws:iam::345594590074:role/ecsTaskExecutionRole", 36 | "executionRoleArn": "arn:aws:iam::345594590074:role/ecsTaskExecutionRole", 37 | "networkMode": "bridge", 38 | "volumes": [], 39 | "placementConstraints": [], 40 | "requiresCompatibilities": [ 41 | "EC2" 42 | ], 43 | "cpu": null, 44 | "memory": null, 45 | "runtimePlatform": { 46 | "cpuArchitecture": "X86_64", 47 | "operatingSystemFamily": "LINUX" 48 | }, 49 | "tags": [] 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/components/CoffeeCarousel.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Carousel } from 'react-bootstrap'; 3 | import '.././App.css'; 4 | 5 | // Helper function to validate URLs 6 | function isValidUrl(url) { 7 | try { 8 | const parsed = new URL(url); 9 | return parsed.protocol === "http:" || parsed.protocol === "https:"; 10 | } catch (e) { 11 | return false; 12 | } 13 | } 14 | 15 | class CoffeeCarousel extends Component { 16 | render() { 17 | const { items } = this.props; 18 | // Only include items that are not null/undefined and have a valid http/https URL 19 | const validItems = (items || []).filter( 20 | item => item && isValidUrl(item.image) 21 | ); 22 | return ( 23 | 24 | {validItems.map((item, index) => ( 25 | 26 | {item.title} 27 | 28 |

{item.title}

29 | {item.ingredients && item.ingredients.length > 0 && ( 30 |
31 | Ingredients:{' '} 32 | {Array.isArray(item.ingredients) 33 | ? item.ingredients.join(', ') 34 | : item.ingredients} 35 |
36 | )} 37 |
38 |
39 | ))} 40 |
41 | ); 42 | } 43 | } 44 | 45 | export default CoffeeCarousel; 46 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/controller/DonateRestController.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.controller; 2 | 3 | import module java.base; 4 | 5 | import io.swagger.v3.oas.annotations.Operation; 6 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 7 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 8 | import net.ironoc.portfolio.graph.DonateItemsResolver; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.http.MediaType; 11 | import org.springframework.http.ResponseEntity; 12 | import org.springframework.web.bind.annotation.GetMapping; 13 | import org.springframework.web.bind.annotation.RequestMapping; 14 | import org.springframework.web.bind.annotation.RestController; 15 | 16 | @RestController 17 | @RequestMapping("/api") 18 | public class DonateRestController { 19 | 20 | @Autowired 21 | private final DonateItemsResolver donateItemsResolver; 22 | 23 | public DonateRestController(DonateItemsResolver donateItemsResolver) { 24 | this.donateItemsResolver = donateItemsResolver; 25 | } 26 | 27 | @Operation(summary = "Get Details of Each Charity Option", 28 | description = "Returns a list of Charity Options & corresponding details.") 29 | @ApiResponses(value = { 30 | @ApiResponse(responseCode = "200", 31 | description = "Successfully retrieved Charity Options.") 32 | }) 33 | @GetMapping(value = {"/donate-items"}, produces= MediaType.APPLICATION_JSON_VALUE) 34 | public ResponseEntity>> getDonateItems() { 35 | // Use the resolver to fetch the Donate/Charity option items 36 | List> donateItems = donateItemsResolver.getDonateItems(); 37 | return ResponseEntity.ok(donateItems); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/controller/PortfolioController.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.controller; 2 | 3 | import module java.base; 4 | 5 | import io.swagger.v3.oas.annotations.Operation; 6 | import io.swagger.v3.oas.annotations.responses.ApiResponse; 7 | import io.swagger.v3.oas.annotations.responses.ApiResponses; 8 | import net.ironoc.portfolio.service.PortfolioItemsResolver; 9 | import org.springframework.http.MediaType; 10 | import org.springframework.http.ResponseEntity; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.web.bind.annotation.GetMapping; 13 | import org.springframework.web.bind.annotation.RequestMapping; 14 | import org.springframework.web.bind.annotation.RestController; 15 | 16 | @RestController 17 | @RequestMapping("/api") 18 | public class PortfolioController { 19 | 20 | private final PortfolioItemsResolver portfolioItemsResolver; 21 | 22 | public PortfolioController(@Autowired PortfolioItemsResolver portfolioItemsResolver) { 23 | this.portfolioItemsResolver = portfolioItemsResolver; 24 | } 25 | 26 | @Operation(summary = "Get Details of Each Portfolio Project item", 27 | description = "Returns a list of Portfolio Project & corresponding details.") 28 | @ApiResponses(value = { 29 | @ApiResponse(responseCode = "200", 30 | description = "Successfully retrieved Portfolio Projects.") 31 | }) 32 | @GetMapping(value = {"/portfolio-items"}, produces= MediaType.APPLICATION_JSON_VALUE) 33 | public ResponseEntity>> getPortfolioItems() { 34 | // Use the resolver to fetch the portfolio items 35 | List> portfolioItems = portfolioItemsResolver.getPortfolioItems(); 36 | return ResponseEntity.ok(portfolioItems); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/aws/AwsSecretManager.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.aws; 2 | 3 | import com.fasterxml.jackson.databind.JsonNode; 4 | import com.fasterxml.jackson.databind.ObjectMapper; 5 | import net.ironoc.portfolio.logger.AbstractLogger; 6 | import org.springframework.stereotype.Component; 7 | import software.amazon.awssdk.regions.Region; 8 | import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient; 9 | import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest; 10 | import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse; 11 | 12 | @Component 13 | public class AwsSecretManager extends AbstractLogger implements SecretManager { 14 | 15 | private static final String SECRET_NAME_GIT_TOKEN = "prod/Ironoc/GitApiToken"; 16 | 17 | private static final String SECRET_KEY = "GIT_API_TOKEN"; 18 | 19 | private static final String REGION = "eu-north-1"; 20 | 21 | @Override 22 | public String getGitSecret() { 23 | Region region = Region.of(REGION); 24 | String secret = ""; 25 | try (SecretsManagerClient client = SecretsManagerClient.builder() 26 | .region(region) 27 | .build()) { 28 | GetSecretValueRequest getSecretValueRequest = GetSecretValueRequest.builder() 29 | .secretId(SECRET_NAME_GIT_TOKEN) 30 | .build(); 31 | 32 | GetSecretValueResponse getSecretValueResponse = client.getSecretValue(getSecretValueRequest); 33 | String json = getSecretValueResponse.secretString(); 34 | 35 | JsonNode node = new ObjectMapper().readTree(json); 36 | 37 | secret = node.get(SECRET_KEY).asText(); 38 | } catch (Exception e) { 39 | error("Unexpected error occurred extracting secret.", e); 40 | } 41 | return secret; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/domain/RepositoryDetailDomain.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.domain; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Builder; 6 | import lombok.Getter; 7 | import lombok.NoArgsConstructor; 8 | import lombok.Setter; 9 | 10 | @Builder 11 | @AllArgsConstructor 12 | @NoArgsConstructor 13 | @Getter 14 | @Setter 15 | public class RepositoryDetailDomain { 16 | 17 | @Schema(name= "name", description = "Name of GitHub Repository.", example = "ironoc-db", 18 | requiredMode = Schema.RequiredMode.REQUIRED) 19 | private String name; 20 | 21 | @Schema(name= "fullName", description = "Full Name of GitHub Repository (Format is: username/project_name).", 22 | example = "conorheffron/ironoc-db", requiredMode = Schema.RequiredMode.REQUIRED) 23 | private String fullName; 24 | 25 | @Schema(name= "description", description = "Description of GitHub project.", example = "Personal Portfolio Website.", 26 | requiredMode = Schema.RequiredMode.NOT_REQUIRED) 27 | private String description; 28 | 29 | @Schema(name= "appHome", description = "Normally this value is the link to the project/app home page.", 30 | example = "http://ironoc.com", requiredMode = Schema.RequiredMode.NOT_REQUIRED) 31 | private String appHome; 32 | 33 | @Schema(name = "repoUrl", description = "This is the home page URL of the project.", 34 | example = "https://github.com/conorheffron/ironoc-db", requiredMode = Schema.RequiredMode.REQUIRED) 35 | private String repoUrl; 36 | 37 | @Schema(name = "topics", description = "Labels or topics associated with the GitHub repository project.", 38 | example = "[aws, jdk21, maven, personal, portfolio, spring-boot-3]", requiredMode = Schema.RequiredMode.NOT_REQUIRED) 39 | private String topics; 40 | 41 | @Schema(name= "issueCount", description = "Number of associated issues.", example = "3") 42 | private int issueCount; 43 | } 44 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/controller/DonateRestControllerTest.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.controller; 2 | 3 | import module java.base; 4 | 5 | import net.ironoc.portfolio.graph.DonateItemsResolver; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.Test; 8 | import org.mockito.InjectMocks; 9 | import org.mockito.Mock; 10 | import org.mockito.MockitoAnnotations; 11 | import org.springframework.http.ResponseEntity; 12 | 13 | import static org.hamcrest.MatcherAssert.assertThat; 14 | import static org.hamcrest.Matchers.is; 15 | import static org.hamcrest.Matchers.notNullValue; 16 | import static org.hamcrest.Matchers.hasSize; 17 | import static org.hamcrest.Matchers.hasEntry; 18 | import static org.mockito.Mockito.when; 19 | import static org.mockito.Mockito.verify; 20 | import static org.mockito.Mockito.times; 21 | 22 | class DonateRestControllerTest { 23 | 24 | @Mock 25 | private DonateItemsResolver donateItemsResolver; 26 | 27 | @InjectMocks 28 | private DonateRestController donateRestController; 29 | 30 | @BeforeEach 31 | void setUp() { 32 | MockitoAnnotations.openMocks(this); 33 | } 34 | 35 | @Test 36 | void testGetDonateItems() { 37 | // Arrange 38 | List> mockDonateItems = new ArrayList<>(); 39 | Map item = new HashMap<>(); 40 | item.put("name", "Charity A"); 41 | item.put("founded", 2000); 42 | mockDonateItems.add(item); 43 | when(donateItemsResolver.getDonateItems()).thenReturn(mockDonateItems); 44 | 45 | // Act 46 | ResponseEntity>> response = donateRestController.getDonateItems(); 47 | 48 | // Assert 49 | assertThat(response, is(notNullValue())); 50 | assertThat(response.getStatusCode().value(), is(200)); 51 | assertThat(response.getBody(), is(notNullValue())); 52 | assertThat(response.getBody(), hasSize(1)); 53 | assertThat(response.getBody().getFirst(), hasEntry("name", "Charity A")); 54 | assertThat(response.getBody().getFirst(), hasEntry("founded", 2000)); 55 | verify(donateItemsResolver, times(1)).getDonateItems(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/resources/json/portfolio-items.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "link": "https://github.com/conorheffron/ironoc-db", 4 | "img": "navy", 5 | "alt": "navy1", 6 | "title": "ironoc-db", 7 | "description": "Sample Data Manager Service with UI", 8 | "techStack": "Java & Spring Boot, Thymeleaf Templating Engine, & MySQL." 9 | }, 10 | { 11 | "link": "https://github.com/conorheffron/booking-sys", 12 | "img": "teal", 13 | "alt": "teal2", 14 | "title": "booking-sys", 15 | "description": "Sample Reservations & Viewer System", 16 | "techStack": "Python & Django Web App, JavaScript, SQLite3 or MySQL database." 17 | }, 18 | { 19 | "link": "https://github.com/conorheffron/nba-stats", 20 | "img": "navy", 21 | "alt": "navy3", 22 | "title": "nba-stats", 23 | "description": "NBA Analytics (Seasons 2015 - 2024): Player Statistics", 24 | "techStack": "Jupyter Notebooks, Python, Pandas, PandasAI, OpenAI GPT-3.5 Turbo, LLM, & Requests / JSON API." 25 | }, 26 | { 27 | "link": "https://github.com/conorheffron/cbio-skin-canc", 28 | "img": "red", 29 | "alt": "red4", 30 | "title": "cbio-skin-canc", 31 | "description": "Skin Cancer Dataset Analysis", 32 | "techStack": "R, dplyr, plotly, knitr, testthat, covr, GIT." 33 | }, 34 | { 35 | "link": "https://github.com/conorheffron/gene-expr", 36 | "img": "navy", 37 | "alt": "navy5", 38 | "title": "gene-expr", 39 | "description": "Breast Cancer Dataset Analysis", 40 | "techStack": "R, ggplot2, dplyr, deseq2-analysis, & R markdown." 41 | }, 42 | { 43 | "link": "https://github.com/conorheffron/bio-cell-red-edge", 44 | "img": "red", 45 | "alt": "red6", 46 | "title": "bio-cell-red-edge", 47 | "description": "Edge Detection of Biological Cell (Image Processing Script)", 48 | "techStack": "Python, sci-kit-image, matplotlib.pyplot, & scipy.ndimage." 49 | }, 50 | { 51 | "link": "https://github.com/conorheffron/global-max-sim-matrix", 52 | "img": "teal", 53 | "alt": "teal7", 54 | "title": "global-max-sim-matrix", 55 | "description": "Compute Global Maximum Similarity Matrix", 56 | "techStack": "R Package, testthat, stringr, & devtools." 57 | } 58 | ] 59 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/job/GitDetailsJob.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.job; 2 | 3 | import net.ironoc.portfolio.config.PropertyConfigI; 4 | import net.ironoc.portfolio.logger.AbstractLogger; 5 | import net.ironoc.portfolio.service.GitProjectCache; 6 | import net.ironoc.portfolio.service.GitRepoCache; 7 | import net.ironoc.portfolio.service.GitDetails; 8 | import jakarta.annotation.PostConstruct; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.scheduling.annotation.Scheduled; 11 | import org.springframework.stereotype.Component; 12 | 13 | @Component 14 | public class GitDetailsJob extends AbstractLogger { 15 | 16 | private final GitRepoCache gitRepoCache; 17 | 18 | private final GitProjectCache gitProjectCache; 19 | 20 | private final GitDetails gitDetails; 21 | 22 | private final PropertyConfigI propertyConfig; 23 | 24 | @Autowired 25 | public GitDetailsJob(GitDetails gitDetails, 26 | GitRepoCache gitRepoCache, 27 | GitProjectCache gitProjectCache, 28 | PropertyConfigI propertyConfig) { 29 | this.gitRepoCache = gitRepoCache; 30 | this.gitDetails = gitDetails; 31 | this.gitProjectCache = gitProjectCache; 32 | this.propertyConfig = propertyConfig; 33 | } 34 | 35 | @PostConstruct 36 | public void populateCache() { 37 | if (propertyConfig.isCacheJobEnabled()) { 38 | triggerJob(); 39 | } else { 40 | warn("The job to pre-populate the cache of GitHub information is disabled."); 41 | } 42 | } 43 | 44 | @Scheduled(cron = "${net.ironoc.portfolio.github.cron-job}") 45 | public void triggerGitDetailsJob() { 46 | if (propertyConfig.isCacheJobEnabled()) { 47 | triggerJob(); 48 | } else { 49 | warn("The job to update the cache of GitHub information is disabled."); 50 | } 51 | } 52 | 53 | private void triggerJob() { 54 | // run background process to update cache 55 | GitDetailsRunnable runnable = new GitDetailsRunnable(gitRepoCache, gitProjectCache, gitDetails, propertyConfig); 56 | runnable.run(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/controller/BrewGraphqlController.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.controller; 2 | 3 | import module java.base; 4 | 5 | import net.ironoc.portfolio.dto.Brew; 6 | import net.ironoc.portfolio.graph.BrewsResolver; 7 | import net.ironoc.portfolio.logger.AbstractLogger; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.graphql.data.method.annotation.QueryMapping; 10 | import org.springframework.graphql.data.method.annotation.SchemaMapping; 11 | import org.springframework.stereotype.Controller; 12 | 13 | @Controller 14 | public class BrewGraphqlController extends AbstractLogger { 15 | 16 | @Autowired 17 | private final BrewsResolver brewsResolver; 18 | 19 | public BrewGraphqlController(BrewsResolver brewsResolver) { 20 | this.brewsResolver = brewsResolver; 21 | } 22 | 23 | @QueryMapping 24 | public Collection> brews() { 25 | return brewsResolver.getBrews(); 26 | } 27 | 28 | @SchemaMapping(typeName = "Query", field = "brewsSchemaMapping") 29 | public Collection brewsSchemaMapping() { 30 | List> brews = brewsResolver.getBrews(); 31 | return mapBrewsToCoffeesList(brews); 32 | } 33 | 34 | private List mapBrewsToCoffeesList(List> brews) { 35 | List coffees = new ArrayList<>(); 36 | for (Map d : brews) { 37 | if (d != null) { 38 | Brew coffee = Brew.builder() 39 | .title(parseValue(d, "title")) 40 | .description(parseValue(d, "description")) 41 | .ingredients(parseValue(d, "ingredients").split(", ")) 42 | .image(parseValue(d, "image")) 43 | .id(Integer.parseInt(parseValue(d, "id"))) 44 | .build(); 45 | coffees.add(coffee); 46 | info("Completed mapping of Brew item, coffee={}", coffee); 47 | } 48 | } 49 | return coffees; 50 | } 51 | 52 | private String parseValue(Map d, String key) { 53 | return d.get(key) == null ? null : d.get(key).toString(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/utils/TestRequestResponseUtils.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.utils; 2 | 3 | import module java.base; 4 | 5 | import net.ironoc.portfolio.domain.CoffeeDomain; 6 | 7 | public class TestRequestResponseUtils { 8 | public static List getSampleCoffeeDomainList() { 9 | List coffeeDomains = new ArrayList<>(); 10 | coffeeDomains.add(CoffeeDomain.builder() 11 | .id(1) 12 | .ingredients(List.of("Coffee", "Ice")) 13 | .image("image_url_1") 14 | .title("Iced Coffee").build()); 15 | coffeeDomains.add(CoffeeDomain.builder() 16 | .id(2) 17 | .ingredients(List.of("Espresso", "Milk")) 18 | .image("image_url_2") 19 | .title("Latte").build()); 20 | return coffeeDomains; 21 | } 22 | 23 | public static Map getSampleResponse(boolean withInvalidIngredientsItem) { 24 | Map response = new HashMap<>(); 25 | List> allHots = new ArrayList<>(); 26 | List> allIce = new ArrayList<>(); 27 | 28 | Map hotCoffee = new HashMap<>(); 29 | hotCoffee.put("id", "1"); 30 | hotCoffee.put("ingredients", List.of("Coffee")); 31 | hotCoffee.put("image", "image_url_1"); 32 | hotCoffee.put("title", "Black Coffee"); 33 | allHots.add(hotCoffee); 34 | 35 | Map icedCoffee = new HashMap<>(); 36 | icedCoffee.put("id", "2"); 37 | icedCoffee.put("ingredients", List.of("Espresso", "Milk")); 38 | icedCoffee.put("image", "image_url_2"); 39 | icedCoffee.put("title", "Latte"); 40 | allIce.add(icedCoffee); 41 | 42 | if (withInvalidIngredientsItem) { 43 | Map icedCoffee2 = new HashMap<>(); 44 | icedCoffee2.put("id", "3"); 45 | icedCoffee2.put("ingredients", "invalid_array"); 46 | icedCoffee2.put("image", "image_url_3"); 47 | icedCoffee2.put("title", "Ice Black"); 48 | allIce.add(icedCoffee2); 49 | } 50 | 51 | response.put("allHots", allHots); 52 | response.put("allIceds", allIce); 53 | return response; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@conorheffron/ironoc-frontend", 3 | "version": "9.0.4", 4 | "private": false, 5 | "license": "GPL-3.0-or-later", 6 | "dependencies": { 7 | "@apollo/client": "^3.13.8", 8 | "@emotion/is-prop-valid": "^1.4.0", 9 | "@emotion/react": "^11.14.0", 10 | "@emotion/styled": "^11.14.1", 11 | "@fontsource/montserrat": "^5.2.8", 12 | "@fontsource/open-sans": "^5.2.7", 13 | "@graphiql/plugin-explorer": "^5.1.1", 14 | "@graphiql/react": "^0.37.1", 15 | "@testing-library/user-event": "^14.6.1", 16 | "axios": "^1.12.2", 17 | "bootstrap": "5.3", 18 | "graphql": "^16.11.0", 19 | "history": "^5.3.0", 20 | "material-react-table": "^3.2.1", 21 | "react": "^19.2.0", 22 | "react-bootstrap": "^2.10.10", 23 | "react-bootstrap-carousel": "^4.1.1", 24 | "react-bootstrap-form": "^0.1.4-beta6", 25 | "react-bootstrap-validation": "^0.1.11", 26 | "react-cookie": "^8.0.1", 27 | "react-dom": "^19.2.0", 28 | "react-router": "^7.9.4", 29 | "react-router-dom": "^7.9.4", 30 | "reactstrap": "^9.2.3", 31 | "recharts": "^3.3.0", 32 | "web-vitals": "^5.1.0" 33 | }, 34 | "scripts": { 35 | "start": "react-scripts start", 36 | "build": "CI=false && GENERATE_SOURCEMAP=false react-scripts build", 37 | "test": "react-scripts test --transformIgnorePatterns \"node_modules/(?!axios)/\"", 38 | "test:coverage": "react-scripts test --transformIgnorePatterns \"node_modules/(?!axios)/\" --env=jsdom --watchAll=false --coverage", 39 | "eject": "react-scripts eject" 40 | }, 41 | "eslintConfig": { 42 | "extends": [ 43 | "react-app", 44 | "react-app/jest" 45 | ] 46 | }, 47 | "browserslist": { 48 | "production": [ 49 | ">0.2%", 50 | "not dead", 51 | "not op_mini all" 52 | ], 53 | "development": [ 54 | "last 1 chrome version", 55 | "last 1 firefox version", 56 | "last 1 safari version" 57 | ] 58 | }, 59 | "devDependencies": { 60 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11", 61 | "@testing-library/jest-dom": "^6.9.1", 62 | "@testing-library/react": "^16.3.0", 63 | "jest": "^30.2.0", 64 | "react-scripts": "5.0.1", 65 | "web-vitals": "^5.1.0" 66 | }, 67 | "resolutions": { 68 | "react-scripts/@svgr/webpack": "^6.2.1" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /frontend/src/components/__tests__/CoffeCarousel.test.js: -------------------------------------------------------------------------------- 1 | import { screen, render } from '@testing-library/react'; 2 | import CoffeeCarousel from '../CoffeeCarousel'; 3 | import ControlledCarousel from '../ControlledCarousel'; 4 | 5 | // Sample data for testing 6 | const coffeeItems = [ 7 | { 8 | title: 'Espresso', 9 | ingredients: ['Water', 'Coffee beans'], 10 | image: 'https://example.com/espresso.jpg', 11 | }, 12 | { 13 | title: 'Cappuccino', 14 | ingredients: ['Espresso', 'Steamed milk', 'Foam milk'], 15 | image: 'https://example.com/cappuccino.jpg', 16 | }, 17 | ]; 18 | 19 | describe('CoffeeCarousel', () => { 20 | test('renders carousel with coffee items', () => { 21 | render(); 22 | 23 | // Check that the carousel items are rendered 24 | coffeeItems.forEach((item) => { 25 | expect(screen.getByText(item.title)).toBeInTheDocument(); 26 | expect(screen.getByAltText(item.title)).toBeInTheDocument(); 27 | expect(screen.getByText(item.ingredients.join(', '))).toBeInTheDocument(); 28 | }); 29 | }); 30 | 31 | test('renders carousel with correct number of items', () => { 32 | render(); 33 | 34 | // Check that the correct number of carousel items are rendered 35 | const carouselItems = screen.getAllByRole('img'); 36 | 37 | expect(carouselItems.length).toBe(coffeeItems.length); 38 | }); 39 | 40 | test('does not render ingredients if item.ingredients is null or an empty array', () => { 41 | const mockItems = [ 42 | { 43 | image: 'http://image1.jpg', 44 | title: 'Coffee with no ingredients', 45 | ingredients: [], 46 | }, 47 | { 48 | image: 'https://image2.jpg', 49 | title: 'Coffee with null ingredients', 50 | ingredients: null, 51 | }, 52 | null 53 | ]; 54 | 55 | render(); 56 | 57 | // Check that the titles are rendered 58 | expect(screen.getByText('Coffee with no ingredients')).toBeInTheDocument(); 59 | expect(screen.getByText('Coffee with null ingredients')).toBeInTheDocument(); 60 | 61 | // Ensure ingredients are NOT rendered 62 | expect(screen.queryByText('Ingredients:')).not.toBeInTheDocument(); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/controller/PortfolioControllerTest.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.controller; 2 | 3 | import module java.base; 4 | 5 | import net.ironoc.portfolio.service.PortfolioItemsResolver; 6 | import org.junit.jupiter.api.BeforeEach; 7 | import org.junit.jupiter.api.Test; 8 | import org.mockito.InjectMocks; 9 | import org.mockito.Mock; 10 | import org.mockito.MockitoAnnotations; 11 | import org.springframework.http.ResponseEntity; 12 | 13 | import static org.hamcrest.MatcherAssert.assertThat; 14 | import static org.hamcrest.Matchers.is; 15 | import static org.hamcrest.Matchers.notNullValue; 16 | import static org.hamcrest.Matchers.hasSize; 17 | import static org.hamcrest.Matchers.hasEntry; 18 | import static org.mockito.Mockito.when; 19 | import static org.mockito.Mockito.verify; 20 | import static org.mockito.Mockito.times; 21 | 22 | class PortfolioControllerTest { 23 | 24 | @Mock 25 | private PortfolioItemsResolver portfolioItemsResolver; 26 | 27 | @InjectMocks 28 | private PortfolioController portfolioController; 29 | 30 | @BeforeEach 31 | void setUp() { 32 | MockitoAnnotations.openMocks(this); 33 | } 34 | 35 | @Test 36 | void testGetPortfolioItems() { 37 | // Arrange 38 | List> mockPortfolioItems = new ArrayList<>(); 39 | Map item = new HashMap<>(); 40 | item.put("link", "https://github.com/conorheffron/ironoc-db"); 41 | item.put("description", "Sample Data Manager Service with UI"); 42 | mockPortfolioItems.add(item); 43 | when(portfolioItemsResolver.getPortfolioItems()).thenReturn(mockPortfolioItems); 44 | 45 | // Act 46 | ResponseEntity>> response = portfolioController.getPortfolioItems(); 47 | 48 | // Assert 49 | assertThat(response, is(notNullValue())); 50 | assertThat(response.getStatusCode().value(), is(200)); 51 | assertThat(response.getBody(), is(notNullValue())); 52 | assertThat(response.getBody(), hasSize(1)); 53 | assertThat(response.getBody().getFirst(), hasEntry("link", "https://github.com/conorheffron/ironoc-db")); 54 | assertThat(response.getBody().getFirst(), hasEntry("description", "Sample Data Manager Service with UI")); 55 | verify(portfolioItemsResolver, times(1)).getPortfolioItems(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish-packages.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | name: Node.js Package 4 | 5 | on: 6 | release: 7 | types: [created] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 24 19 | cache: 'npm' 20 | cache-dependency-path: './frontend/package-lock.json' 21 | - run: npm cache clean --force 22 | working-directory: './frontend' 23 | - run: npm install 24 | working-directory: './frontend' 25 | - run: npm ci 26 | working-directory: './frontend' 27 | 28 | publish-gpr: 29 | needs: build 30 | runs-on: ubuntu-latest 31 | permissions: 32 | contents: read 33 | packages: write 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: actions/setup-node@v4 37 | with: 38 | node-version: '24.x' 39 | registry-url: https://npm.pkg.github.com/ 40 | scope: '@conorheffron' 41 | - run: npm set "//npm.pkg.github.com/:_authToken=$NPM_TOKEN" 42 | working-directory: './frontend' 43 | env: 44 | NPM_TOKEN: ${{secrets.GITHUB_TOKEN}} 45 | - run: echo registry=https://npm.pkg.github.com/conorheffron >> .npmrc 46 | working-directory: './frontend' 47 | - run: npm publish 48 | working-directory: './frontend' 49 | env: 50 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 51 | 52 | publish-npm: 53 | needs: build 54 | runs-on: ubuntu-latest 55 | permissions: 56 | contents: read 57 | packages: write 58 | steps: 59 | - uses: actions/checkout@v4 60 | - uses: actions/setup-node@v4 61 | with: 62 | node-version: '24.x' 63 | registry-url: https://registry.npmjs.org/ 64 | - run: npm set "//registry.npmjs.org/conorheffron/:_authToken=$NPM_TOKEN" 65 | working-directory: './frontend' 66 | env: 67 | NPM_TOKEN: ${{secrets.NODE_AUTH_TOKEN}} 68 | - run: npm publish --access public 69 | working-directory: './frontend' 70 | env: 71 | NODE_AUTH_TOKEN: ${{secrets.NODE_AUTH_TOKEN}} 72 | -------------------------------------------------------------------------------- /frontend/src/components/__tests__/ControlledCarousel.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react'; 2 | import App from '../../App'; 3 | import { Router, MemoryRouter } from 'react-router'; 4 | import ControlledCarousel from '../ControlledCarousel'; 5 | import LoadingSpinner from '../../LoadingSpinner'; 6 | 7 | // Mock API response 8 | const mockPortfolioItems = [ 9 | { 10 | link: "https://github.com/conorheffron/ironoc-db", 11 | img: "navy", 12 | alt: "navy1", 13 | title: "ironoc-db", 14 | description: "Sample Data Manager Service with UI", 15 | techStack: "Java & Spring Boot, Thymeleaf Templating Engine, & MySQL." 16 | }, 17 | { 18 | link: "https://github.com/conorheffron/booking-sys", 19 | img: "teal", 20 | alt: "teal2", 21 | title: "booking-sys", 22 | description: "Sample Reservations & Viewer System", 23 | techStack: "Python & Django Web App, JavaScript, SQLite3 or MySQL database." 24 | } 25 | ]; 26 | 27 | describe('Portfolio Controlled Carousel', () => { 28 | beforeEach(() => { 29 | jest.spyOn(global, 'fetch').mockResolvedValue({ 30 | json: jest.fn().mockResolvedValue(mockPortfolioItems) 31 | }); 32 | }); 33 | 34 | afterEach(() => { 35 | jest.restoreAllMocks(); 36 | }); 37 | 38 | test('renders AppNavbar component', () => { 39 | render(); 40 | expect(screen.getByRole('banner')).toBeInTheDocument(); 41 | }); 42 | 43 | test('renders loading state initially', () => { 44 | render(); 45 | expect(screen.getByText('Loading...')).toBeInTheDocument(); 46 | }); 47 | 48 | test('displays portfolio items after fetching data', async () => { 49 | const { container } = render( 50 | 51 | 52 | 53 | ); 54 | 55 | // Wait for the component to finish loading 56 | await waitFor(() => { 57 | expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); 58 | }); 59 | 60 | // Check that portfolio items are displayed 61 | mockPortfolioItems.forEach(item => { 62 | expect(screen.getByText(item.title)).toBeInTheDocument(); 63 | expect(screen.getByText(item.description)).toBeInTheDocument(); 64 | expect(screen.getByText(item.techStack)).toBeInTheDocument(); 65 | }); 66 | 67 | // Verify that the component does not show the loading spinner 68 | expect(container.querySelector('.LoadingSpinner')).not.toBeInTheDocument(); 69 | }); 70 | }); 71 | 72 | -------------------------------------------------------------------------------- /.github/workflows/aws.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Amazon ECS 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | env: 8 | AWS_REGION: eu-north-1 9 | ECR_REPOSITORY: conorheffron/ironoc 10 | ECS_SERVICE: IronocService 11 | ECS_CLUSTER: IronocPortfolioCluster 12 | ECS_TASK_DEFINITION: .aws/task-definition.json 13 | CONTAINER_NAME: ironoc 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | name: Deploy 21 | runs-on: ubuntu-latest 22 | environment: production 23 | 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | 28 | - name: Set up JDK 25 29 | uses: actions/setup-java@v3 30 | with: 31 | java-version: '25' 32 | distribution: 'temurin' 33 | cache: maven 34 | - name: Build with Maven 35 | run: ./mvnw -B package --file pom.xml 36 | 37 | - name: Configure AWS credentials 38 | uses: aws-actions/configure-aws-credentials@v1 39 | with: 40 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 41 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 42 | aws-region: ${{ env.AWS_REGION }} 43 | 44 | - name: Login to Amazon ECR 45 | id: login-ecr 46 | uses: aws-actions/amazon-ecr-login@v1 47 | 48 | - name: Build, tag, and push image to Amazon ECR 49 | id: build-image 50 | env: 51 | ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} 52 | IMAGE_TAG: ${{ github.sha }} 53 | run: | 54 | # Build a docker container and 55 | # push it to ECR so that it can 56 | # be deployed to ECS. 57 | docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . 58 | docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG 59 | echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT 60 | 61 | - name: Fill in the new image ID in the Amazon ECS task definition 62 | id: task-def 63 | uses: aws-actions/amazon-ecs-render-task-definition@v1 64 | with: 65 | task-definition: ${{ env.ECS_TASK_DEFINITION }} 66 | container-name: ${{ env.CONTAINER_NAME }} 67 | image: ${{ steps.build-image.outputs.image }} 68 | 69 | - name: Deploy Amazon ECS task definition 70 | uses: aws-actions/amazon-ecs-deploy-task-definition@v1 71 | with: 72 | task-definition: ${{ steps.task-def.outputs.task-definition }} 73 | service: ${{ env.ECS_SERVICE }} 74 | cluster: ${{ env.ECS_CLUSTER }} 75 | wait-for-service-stability: true 76 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/service/GraphQLClientServiceTest.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import com.fasterxml.jackson.databind.ObjectMapper; 6 | import net.ironoc.portfolio.config.PropertyConfigI; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.mockito.InjectMocks; 10 | import org.mockito.Mock; 11 | import org.mockito.junit.jupiter.MockitoExtension; 12 | import org.springframework.boot.restclient.RestTemplateBuilder; 13 | import org.springframework.core.io.ResourceLoader; 14 | import org.springframework.web.client.RestTemplate; 15 | 16 | import static org.hamcrest.MatcherAssert.assertThat; 17 | import static org.hamcrest.Matchers.is; 18 | import static org.hamcrest.Matchers.notNullValue; 19 | 20 | @ExtendWith(MockitoExtension.class) 21 | public class GraphQLClientServiceTest { 22 | 23 | @Mock 24 | private ObjectMapper objectMapper; 25 | 26 | @Mock 27 | private ResourceLoader resourceLoader; 28 | 29 | @Mock 30 | private RestTemplate restTemplate; 31 | 32 | @Mock 33 | private RestTemplateBuilder restTemplateBuilder; 34 | 35 | @Mock 36 | private PropertyConfigI propertyConfig; 37 | 38 | @InjectMocks 39 | private GraphQLClientService graphQLClientService; 40 | 41 | @Test 42 | public void test_getAllIcedCoffees_success() { 43 | // given 44 | Map response = new HashMap<>(); 45 | Map data = new HashMap<>(); 46 | data.put("allIceds", List.of(Map.of("name", "Iced Coffee"))); 47 | response.put("data", data); 48 | 49 | // when 50 | List> result = graphQLClientService.getAllIcedCoffees(response); 51 | 52 | // then 53 | assertThat(result, is(notNullValue())); 54 | assertThat(result.size(), is(1)); 55 | assertThat(result.getFirst().get("name"), is("Iced Coffee")); 56 | } 57 | 58 | @Test 59 | public void test_getAllHotCoffees_success() { 60 | // given 61 | Map response = new HashMap<>(); 62 | Map data = new HashMap<>(); 63 | data.put("allHots", List.of(Map.of("name", "Hot Coffee"))); 64 | response.put("data", data); 65 | 66 | // when 67 | List> result = graphQLClientService.getAllHotCoffees(response); 68 | 69 | // then 70 | assertThat(result, is(notNullValue())); 71 | assertThat(result.size(), is(1)); 72 | assertThat(result.getFirst().get("name"), is("Hot Coffee")); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | net: 2 | ironoc: 3 | portfolio: 4 | brew: 5 | job-enable: false 6 | cron-job: "0 0 2 */3 * ?" 7 | api: 8 | endpoint: 9 | ice: https://api.sampleapis.com/coffee/iced 10 | hot: https://api.sampleapis.com/coffee/hot 11 | graph: 12 | endpoint: https://api.sampleapis.com/coffee/graphql 13 | github: 14 | job-enable: true 15 | cron-job: "0 1 1 */3 * ?" 16 | timeout: 17 | connect: 100000 18 | read: 100000 19 | instance-follow-redirects: true 20 | follow-redirects: true 21 | api: 22 | endpoint: 23 | repos: https://api.github.com/users/{username}/repos?per_page={per_page}&page={page} 24 | user-ids-cache: conorheffron 25 | issues: https://api.github.com/repos/{username}/{repo}/issues?per_page={per_page}&page={page} 26 | projects-cache: > 27 | bio-cell-red-edge, 28 | booking-sys, 29 | cart-dart, 30 | cbio-skin-canc, 31 | elastic-tester, 32 | gene-expr, 33 | global-max-sim-matrix, 34 | go-graph, 35 | hl7-hapi-fhir-obs-api, 36 | kafka-consumer-check, 37 | kafka-producer-check, 38 | ironoc, 39 | ironoc-db, 40 | ironoc-msg, 41 | ironoc-pytest, 42 | ironoc-rustt, 43 | ironoc-spark, 44 | mcp-charity, 45 | mern-sandbox, 46 | nba-stats, 47 | normalise-fetalh, 48 | py-flask-graph, 49 | rabbitmq-tester, 50 | redis-tester, 51 | rules-engine, 52 | sb-kafka-listener, 53 | shopping-cart-go, 54 | shoppingcart-java, 55 | shopping-cart-kotlin, 56 | shoppingcart-scala 57 | config: 58 | ignore-paths: api,graphiql 59 | handle-extensions: "html,js,json,css,map,svg,eot,ttf,woff,png,jpg,jpeg,gif,ico" 60 | resource-handler: "/**" 61 | resource-loc: "classpath:/static/" 62 | 63 | spring: 64 | mvc: 65 | favicon: 66 | enabled: false 67 | banner: 68 | location: classpath:/static/ironoc-banner.txt 69 | graphql: 70 | graphiql: 71 | enabled: true 72 | schema: 73 | fileExtensions: .graphql 74 | 75 | springdoc: 76 | api-docs: 77 | path: /api-docs 78 | enabled: true 79 | swagger-ui: 80 | path: /swagger-ui-ironoc.html 81 | operationsSorter: method 82 | enabled: true 83 | 84 | server: 85 | forward-headers-strategy: framework 86 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/config/PropertyKey.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.config; 2 | 3 | import org.springframework.stereotype.Component; 4 | 5 | @Component 6 | public class PropertyKey implements PropertyKeyI { 7 | 8 | @Override 9 | public String getGitApiEndpointRepos() { 10 | return Properties.GIT_API_ENDPOINT_REPOS.getKey(); 11 | } 12 | 13 | @Override 14 | public String getGitApiEndpointIssues() { 15 | return Properties.GIT_API_ENDPOINT_ISSUES.getKey(); 16 | } 17 | 18 | @Override 19 | public String getGitTimeoutConnect() { 20 | return Properties.GIT_TIMEOUT_CONNECT.getKey(); 21 | } 22 | 23 | @Override 24 | public String getGitTimeoutRead() { 25 | return Properties.GIT_TIMEOUT_READ.getKey(); 26 | } 27 | 28 | @Override 29 | public String getGitInstanceFollowRedirects() { 30 | return Properties.GIT_INSTANCE_FOLLOW_REDIRECTS.getKey(); 31 | } 32 | 33 | @Override 34 | public String getGitFollowRedirects() { 35 | return Properties.GIT_FOLLOW_REDIRECTS.getKey(); 36 | } 37 | 38 | @Override 39 | public String getStaticConfIgnorePaths() { 40 | return Properties.STATIC_CONF_IGNORE_PATHS.getKey(); 41 | } 42 | 43 | @Override 44 | public String getStaticConfHandleExt() { 45 | return Properties.STATIC_CONF_HANDLE_EXT.getKey(); 46 | } 47 | 48 | @Override 49 | public String getStaticConfResourceHandler() { 50 | return Properties.STATIC_CONF_RESOURCE_HANDLER.getKey(); 51 | } 52 | 53 | @Override 54 | public String getStaticConfResourceLoc() { 55 | return Properties.STATIC_CONF_RESOURCE_LOC.getKey(); 56 | } 57 | 58 | @Override 59 | public String getGitApiEndpointUserIdsCache() { 60 | return Properties.GIT_API_ENDPOINT_REPOS_PARAM_CACHE.getKey(); 61 | } 62 | 63 | @Override 64 | public String getGitApiEndpointProjectsCache() { 65 | return Properties.GIT_API_ENDPOINT_ISSUES_PARAM_CACHE.getKey(); 66 | } 67 | 68 | @Override 69 | public String isCacheJobEnabled() { 70 | return Properties.IS_GITHUB_JOB_ENABLED.getKey(); 71 | } 72 | 73 | @Override 74 | public String getBrewApiEndpointHot() { 75 | return Properties.BREWS_API_ENDPOINT_HOT.getKey(); 76 | } 77 | 78 | @Override 79 | public String getBrewApiEndpointIce() { 80 | return Properties.BREWS_API_ENDPOINT_ICE.getKey(); 81 | } 82 | 83 | @Override 84 | public String getBrewGraphEndpoint() { 85 | return Properties.BREWS_GRAPH_ENDPOINT.getKey(); 86 | } 87 | 88 | @Override 89 | public String isBrewCacheJobEnabled() { 90 | return Properties.IS_BREWS_JOB_ENABLED.getKey(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { BrowserRouter as Router, Routes, Route } from 'react-router'; 3 | import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client'; 4 | import './App.css'; 5 | import Home from './components/Home'; 6 | import CoffeeHome from './components/CoffeeHome'; 7 | import Donate from './components/Donate'; 8 | import NotFound from './components/NotFound'; 9 | import About from './components/About'; 10 | import RepoDetails from './components/RepoDetails'; 11 | import RepoIssues from './components/RepoIssues'; 12 | import ControlledCarousel from './components/ControlledCarousel'; 13 | 14 | class App extends Component { 15 | render() { 16 | // Default props 17 | const { 18 | forceRefresh = true, 19 | routes = [ 20 | { path: '/', exact: true, component: Home }, 21 | { path: '/about', exact: true, component: About }, 22 | { path: '/portfolio', exact: true, component: ControlledCarousel }, 23 | { path: '/projects', exact: true, component: RepoDetails }, 24 | { path: '/projects/:id', component: RepoDetails }, 25 | { path: '/issues/:id/:repo', component: RepoIssues }, 26 | { path: '/brews', exact: true, component: CoffeeHome }, 27 | { path: '/donate', exact: true, component: Donate }, 28 | { path: '*', component: NotFound } 29 | ] 30 | } = this.props; 31 | 32 | // Create Apollo Client for '/donate' route 33 | const donateClient = new ApolloClient({ 34 | uri: '/graphql', // Replace with your GraphQL endpoint 35 | cache: new InMemoryCache(), 36 | }); 37 | 38 | return ( 39 | 40 | 41 | {routes.map((route, index) => { 42 | // Wrap the Donate component with ApolloProvider 43 | if (route.path === '/donate') { 44 | return ( 45 | 51 | 52 | 53 | } 54 | /> 55 | ); 56 | } 57 | 58 | // Return other routes as usual (using axios, fetch, Jest etc.) 59 | return ( 60 | } 65 | /> 66 | ); 67 | })} 68 | 69 | 70 | ); 71 | } 72 | } 73 | 74 | export default App; 75 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 37vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | min-height: 90vh; 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | justify-content: center; 22 | font-size: calc(10px + 2vmin); 23 | color: white; 24 | background-color: #1D428A; 25 | border-bottom-right-radius: 18%; 26 | border-bottom-left-radius: 18%; 27 | } 28 | 29 | .App-link { 30 | color: #61dafb; 31 | } 32 | 33 | @keyframes App-logo-spin { 34 | from { 35 | transform: rotate(0deg); 36 | } 37 | to { 38 | transform: rotate(360deg); 39 | } 40 | } 41 | 42 | .App-header, .body, .table-headers, .table, .mb-3 { 43 | font-family: "Open Sans"; 44 | font-size: 1.4em; 45 | } 46 | 47 | .nav-bar, .ft, h1, h3 { 48 | font-family: "Montserrat"; 49 | font-size: 1.1em; 50 | } 51 | 52 | .carousel-caption { 53 | top: 0; 54 | bottom: auto; 55 | } 56 | 57 | .App-intro { 58 | content: ""; 59 | display: inline-block; 60 | } 61 | 62 | #my-intro { 63 | padding-left: 15%; 64 | padding-right: 15%; 65 | } 66 | 67 | .strava-badge { 68 | display: inline-block; 69 | background-color: #FC5200; 70 | color: #fff; 71 | padding: 5px 10px 5px 30px; 72 | font-size: 11px; 73 | font-family: Helvetica, Arial, sans-serif; 74 | white-space: nowrap; 75 | text-decoration: none; 76 | background-repeat: no-repeat; 77 | background-position: 10px center; 78 | border-radius: 3px; 79 | background-image: url('https://badges.strava.com/logo-strava-echelon.png') 80 | } 81 | 82 | .strava-badge-img { 83 | margin-left: 2px; 84 | vertical-align: text-bottom; 85 | height: 13; 86 | width: 51; 87 | } 88 | 89 | .carousel-caption { 90 | font-family: "Open Sans"; 91 | } 92 | 93 | .carousel-caption h3 { 94 | color: navy; 95 | } 96 | 97 | .carousel-caption h5 { 98 | color: navy; 99 | } 100 | 101 | p a { 102 | color: yellow; 103 | } 104 | 105 | footer p a { 106 | color: blue; 107 | } 108 | 109 | .carousel-caption h3, h5 { 110 | background-color:yellow; 111 | width: 50%; 112 | height: auto; 113 | margin: 0 auto; 114 | } 115 | 116 | .carousel-item img { 117 | width: 50%; 118 | height: auto; 119 | margin: 0 auto; 120 | } 121 | 122 | .overview-text { 123 | display: block; 124 | font-size: 1rem; /* Adjust the base font-size as needed */ 125 | line-height: 1.5; 126 | white-space: normal; 127 | word-wrap: break-word; 128 | overflow-wrap: break-word; 129 | max-height: 200px; /* Set a max height to avoid overflow */ 130 | overflow: hidden; 131 | text-overflow: ellipsis; /* Add ellipsis for text overflow */ 132 | } 133 | -------------------------------------------------------------------------------- /frontend/src/components/ControlledCarousel.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import Carousel from 'react-bootstrap/Carousel'; 3 | import 'bootstrap/dist/css/bootstrap.css'; 4 | import '.././App.css'; 5 | import { Container } from 'reactstrap'; 6 | import AppNavbar from '.././AppNavbar'; 7 | import navy from '.././img/darkblue-bg.png'; 8 | import teal from '.././img/teal-bg.png'; 9 | import red from '.././img/red-bg.png'; 10 | import LoadingSpinner from '.././LoadingSpinner'; 11 | 12 | class ControlledCarousel extends Component { 13 | constructor(props) { 14 | super(props); 15 | this.state = { 16 | portfolioItems: [], 17 | loading: true 18 | }; 19 | } 20 | 21 | async componentDidMount() { 22 | const response = await fetch("/api/portfolio-items"); 23 | const body = await response.json(); 24 | this.setState({ portfolioItems: body, loading: false }); 25 | } 26 | 27 | render() { 28 | const { portfolioItems, loading} = this.state; 29 | 30 | if (loading) { 31 | return ( 32 |
33 | 34 | 35 |


36 | 37 |
38 |
39 | ); 40 | } 41 | 42 | const handleColor = (color) => { 43 | switch (color) { 44 | case "red": 45 | return red; 46 | case "teal": 47 | return teal; 48 | case "navy": 49 | return navy; 50 | default: 51 | return red; 52 | } 53 | }; 54 | 55 | return ( 56 |
57 | 58 | 59 | 60 | {portfolioItems.map((item, index) => ( 61 | 62 | 63 | {item.alt} 64 | 65 |

{item.title}

66 |

{item.description}

67 |

68 |

Tech Stack:

69 |

{item.techStack}

70 |
71 |
72 |
73 | ))} 74 |
75 |
76 |
77 | ); 78 | } 79 | } 80 | 81 | export default ControlledCarousel; 82 | -------------------------------------------------------------------------------- /frontend/src/components/About.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Container } from 'reactstrap'; 3 | import '.././App.css'; 4 | import AppNavbar from '.././AppNavbar'; 5 | 6 | class About extends Component { 7 | render() { 8 | const { 9 | link = "https://www.linkedin.com/in/conorheffron", 10 | imgSrc = "https://static.licdn.com/scds/common/u/img/webpromo/btn_viewmy_160x33.png", 11 | imgWidth = 160, 12 | imgHeight = 33, 13 | imgAlt = "View Conor Heffron's profile on LinkedIn", 14 | stravaLink = 'https://strava.com/athletes/2582329', 15 | stravaImgSrc = 'https://badges.strava.com/logo-strava.png', 16 | stravaImgAlt = 'Strava' 17 | } = this.props; 18 | 19 | return ( 20 |
21 | 22 | 23 |
24 |

25 | 26 | {imgAlt} 27 | 28 |

29 |
30 | Welcome, I'm Conor Heffron, a Software Engineer hailing from County Meath, Ireland. 31 | With over fourteen years of professional experience, I specialize in writing clean code and 32 | developing high-performance applications. As a passionate Full Stack Developer, I am constantly 33 | expanding my technical expertise across various tech stacks, languages, frameworks, and tools in the 34 | realm of Software Engineering, Data Engineering, & DevOps. 35 |

36 | I believe in continuous learning & practical skills that can be demonstrated in a positive & collaborative 37 | manner (open source is great!). When not learning or working, I like jogging/cycling, music, cooking, 38 | pretending to be a caffeine connoisseur, & searching for new forms of 39 | salsa verde / green sauce! 40 |

41 | Let's connect and explore exciting 42 | opportunities together! See above & beyond for contact details and further information. 43 |


44 | 45 | Follow me on 46 | {stravaImgAlt} 47 |
48 |
49 |
50 |
51 | ); 52 | } 53 | } 54 | 55 | export default About; 56 | -------------------------------------------------------------------------------- /frontend/src/components/__tests__/Donate.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen, waitFor } from '@testing-library/react'; 3 | import { MockedProvider } from '@apollo/client/testing'; 4 | import Donate, { GET_DONATE_ITEMS } from '../Donate'; 5 | import '@testing-library/jest-dom'; 6 | 7 | // Mock data for testing 8 | const mockDonateItems = [ 9 | { 10 | __typename: 'DonateItem', 11 | donate: 'https://example.com/donate1', 12 | link: 'https://example.com/home1', 13 | img: 'red', 14 | alt: 'Item 1 Alt Text', 15 | name: 'Item 1', 16 | overview: 'This is an overview of Item 1.', 17 | founded: '2001', 18 | phone: '+123456789', 19 | }, 20 | { 21 | __typename: 'DonateItem', 22 | donate: 'https://example.com/donate2', 23 | link: 'https://example.com/home2', 24 | img: 'blue', 25 | alt: 'Item 2 Alt Text', 26 | name: 'Item 2', 27 | overview: 'This is an overview of Item 2.', 28 | founded: '2010', 29 | phone: '+987654321', 30 | }, 31 | ]; 32 | 33 | // Mock Apollo Client responses 34 | const mocks = [ 35 | { 36 | request: { 37 | query: GET_DONATE_ITEMS, 38 | }, 39 | result: { 40 | data: { 41 | donateItems: mockDonateItems, 42 | }, 43 | }, 44 | }, 45 | ]; 46 | 47 | // Mock Apollo Client with an error response 48 | const errorMocks = [ 49 | { 50 | request: { 51 | query: GET_DONATE_ITEMS, 52 | }, 53 | error: new Error('Network error'), 54 | }, 55 | ]; 56 | 57 | describe('Donate Component', () => { 58 | it('renders loading spinner initially', () => { 59 | render( 60 | 61 | 62 | 63 | ); 64 | 65 | // Verify the loading spinner is displayed 66 | expect(screen.getByText(/Loading.../i)).toBeInTheDocument(); 67 | }); 68 | 69 | it('renders error message when GraphQL query fails', async () => { 70 | render( 71 | 72 | 73 | 74 | ); 75 | 76 | // Wait for the error message to appear 77 | await waitFor(() => { 78 | expect(screen.getByText(/Error loading data: Network error/i)).toBeInTheDocument(); 79 | }); 80 | }); 81 | 82 | it('renders donate items after successful fetch', async () => { 83 | render( 84 | 85 | 86 | 87 | ); 88 | 89 | // Wait for the data to load 90 | await waitFor(() => { 91 | mockDonateItems.forEach((item) => { 92 | expect(screen.getByText(item.name)).toBeInTheDocument(); 93 | expect(screen.getByText(new RegExp(`Founded in ${item.founded}`, 'i'))).toBeInTheDocument(); 94 | }); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/job/GitDetailsRunnableTest.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.job; 2 | 3 | import module java.base; 4 | 5 | import net.ironoc.portfolio.config.PropertyConfigI; 6 | import net.ironoc.portfolio.dto.RepositoryDetailDto; 7 | import net.ironoc.portfolio.dto.RepositoryIssueDto; 8 | import net.ironoc.portfolio.service.GitDetails; 9 | import net.ironoc.portfolio.service.GitProjectCache; 10 | import net.ironoc.portfolio.service.GitRepoCache; 11 | import org.junit.jupiter.api.BeforeEach; 12 | import org.junit.jupiter.api.Test; 13 | import org.junit.jupiter.api.extension.ExtendWith; 14 | import org.mockito.Mock; 15 | import org.mockito.junit.jupiter.MockitoExtension; 16 | 17 | import static org.hamcrest.Matchers.emptyIterable; 18 | import static org.hamcrest.Matchers.hasSize; 19 | import static org.mockito.ArgumentMatchers.anyBoolean; 20 | import static org.mockito.ArgumentMatchers.anyList; 21 | import static org.mockito.ArgumentMatchers.anyString; 22 | import static org.hamcrest.MatcherAssert.assertThat; 23 | import static org.mockito.Mockito.times; 24 | import static org.mockito.Mockito.when; 25 | import static org.mockito.Mockito.verify; 26 | 27 | @ExtendWith(MockitoExtension.class) 28 | public class GitDetailsRunnableTest { 29 | 30 | @Mock 31 | private GitDetails gitDetailsMock; 32 | 33 | @Mock 34 | private GitRepoCache gitRepoCacheMock; 35 | 36 | @Mock 37 | private GitProjectCache gitProjectCacheMock; 38 | 39 | @Mock 40 | private RepositoryIssueDto repositoryIssueDtoMock; 41 | 42 | @Mock 43 | private RepositoryDetailDto repositoryDetailDtoMock; 44 | 45 | @Mock 46 | private PropertyConfigI propertyConfigMock; 47 | 48 | private GitDetailsRunnable gitDetailsRunnable; 49 | 50 | @BeforeEach 51 | public void setup() { 52 | when(propertyConfigMock.getGitApiEndpointUserIdsCache()).thenReturn(List.of("conorheffron")); 53 | when(propertyConfigMock.getGitApiEndpointProjectsCache()) 54 | .thenReturn(List.of("ironoc", "ironoc-db", "booking-sys")); 55 | 56 | gitDetailsRunnable = new GitDetailsRunnable(gitRepoCacheMock, gitProjectCacheMock, gitDetailsMock, propertyConfigMock); 57 | } 58 | 59 | @Test 60 | public void test_run_tearDown_success() { 61 | // given 62 | when(gitDetailsMock.getRepoDetails(anyString(), anyBoolean())) 63 | .thenReturn(Collections.singletonList(repositoryDetailDtoMock)); 64 | when(gitDetailsMock.getIssues(anyString(), anyString(), anyBoolean())) 65 | .thenReturn(Collections.singletonList(repositoryIssueDtoMock)); 66 | 67 | // when 68 | gitDetailsRunnable.run(); 69 | 70 | // then 71 | verify(propertyConfigMock).getGitApiEndpointProjectsCache(); 72 | verify(propertyConfigMock).getGitApiEndpointUserIdsCache(); 73 | verify(gitDetailsMock).getRepoDetails(anyString(), anyBoolean()); 74 | verify(gitRepoCacheMock).put(anyString(), anyList()); 75 | verify(gitDetailsMock).mapRepositoriesToResponse(anyList()); 76 | verify(gitProjectCacheMock, times(3)).put(anyString(), anyString(), anyList()); 77 | 78 | assertThat(gitDetailsRunnable.getUserIds(), hasSize(1)); 79 | 80 | gitDetailsRunnable.tearDown(); 81 | assertThat(gitDetailsRunnable.getUserIds(), emptyIterable()); 82 | } 83 | } 84 | 85 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/config/IronocConfiguration.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.config; 2 | 3 | import module java.base; 4 | 5 | import com.fasterxml.jackson.databind.ObjectMapper; 6 | import io.swagger.v3.oas.models.OpenAPI; 7 | import io.swagger.v3.oas.models.info.Info; 8 | import io.swagger.v3.oas.models.info.License; 9 | import net.ironoc.portfolio.resolver.PushStateResourceResolver; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.info.BuildProperties; 12 | import org.springframework.boot.web.server.WebServerFactoryCustomizer; 13 | import org.springframework.boot.web.server.servlet.ConfigurableServletWebServerFactory; 14 | import org.springframework.context.annotation.Bean; 15 | import org.springframework.context.annotation.ComponentScan; 16 | import org.springframework.context.annotation.Configuration; 17 | import org.springframework.http.HttpMethod; 18 | import org.springframework.scheduling.annotation.EnableAsync; 19 | import org.springframework.scheduling.annotation.EnableScheduling; 20 | import org.springframework.web.servlet.config.annotation.CorsRegistry; 21 | import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer; 22 | import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; 23 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 24 | 25 | @Configuration 26 | @EnableAsync 27 | @EnableScheduling 28 | @ComponentScan(basePackages = { "net.ironoc.portfolio" }) 29 | public class IronocConfiguration implements WebMvcConfigurer { 30 | 31 | private final PropertyConfigI propertyConfig; 32 | 33 | @Autowired 34 | public IronocConfiguration(PropertyConfigI propertyConfig) { 35 | this.propertyConfig = propertyConfig; 36 | } 37 | 38 | @Override 39 | public void addResourceHandlers(ResourceHandlerRegistry registry) { 40 | List ignorePaths = List.of(propertyConfig.getStaticConfIgnorePaths()); 41 | List handledExtensions = propertyConfig.getStaticConfHandleExt(); 42 | registry.addResourceHandler(propertyConfig.getStaticConfResourceHandler()) 43 | .addResourceLocations(propertyConfig.getStaticConfResourceLoc()) 44 | .resourceChain(false) 45 | .addResolver(new PushStateResourceResolver(handledExtensions, ignorePaths)); 46 | } 47 | 48 | @Override 49 | public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) { 50 | configurer.enable(); 51 | } 52 | 53 | @Bean 54 | public WebServerFactoryCustomizer enableDefaultServlet() { 55 | return (factory) -> factory.setRegisterDefaultServlet(true); 56 | } 57 | 58 | @Bean 59 | public ObjectMapper objectMapper() { 60 | return new ObjectMapper(); 61 | } 62 | 63 | @Override 64 | public void addCorsMappings(CorsRegistry registry) { 65 | registry.addMapping("/api/**").allowedMethods(HttpMethod.GET.name()); 66 | } 67 | 68 | @Bean 69 | public OpenAPI ironocOpenAPI(@Autowired BuildProperties buildProperties) { 70 | return new OpenAPI().info(new Info().title("iRonoc API").version("v" + buildProperties.getVersion()) 71 | .license(new License().name("GPL-3.0 license").url("https://ironoc.net"))); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/graph/BrewsResolverTest.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.graph; 2 | 3 | import module java.base; 4 | 5 | import com.fasterxml.jackson.core.type.TypeReference; 6 | import com.fasterxml.jackson.databind.ObjectMapper; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.Test; 9 | import org.mockito.Mockito; 10 | import org.springframework.core.io.ClassPathResource; 11 | 12 | import static org.mockito.Mockito.doThrow; 13 | import static org.mockito.Mockito.mock; 14 | import static org.mockito.Mockito.any; 15 | 16 | import static org.junit.jupiter.api.Assertions.assertFalse; 17 | import static org.junit.jupiter.api.Assertions.assertTrue; 18 | import static org.junit.jupiter.api.Assertions.assertNotNull; 19 | import static org.junit.jupiter.api.Assertions.fail; 20 | 21 | class BrewsResolverTest { 22 | 23 | private BrewsResolver brewsResolver; 24 | 25 | @BeforeEach 26 | void setUp() { 27 | brewsResolver = new BrewsResolver(); 28 | } 29 | 30 | @Test 31 | void getBrews_returnsList_whenJsonExists() { 32 | // This test assumes the test/resources/json/brews.json exists and is valid. 33 | List> brews = brewsResolver.getBrews(); 34 | assertNotNull(brews, "Returned list should not be null"); 35 | assertFalse(brews.isEmpty(), "Returned list should not be empty (expecting brews in test json)"); 36 | assertTrue(brews.getFirst().containsKey("title"), "First element should contain the key 'title'"); 37 | } 38 | 39 | @Test 40 | void getBrews_returnsEmptyList_whenJsonDoesNotExist() { 41 | // Temporarily change BREWS_JSON_FILE to a non-existent file via subclassing 42 | BrewsResolver brokenResolver = new BrewsResolver() { 43 | @Override 44 | public List> getBrews() { 45 | ObjectMapper objectMapper = new ObjectMapper(); 46 | try { 47 | return objectMapper.readValue( 48 | new ClassPathResource("json/doesnotexist.json").getInputStream(), 49 | new TypeReference<>() {}); 50 | } catch (IOException e) { 51 | error("Failed to load Brews JSON", e); 52 | } 53 | return Collections.emptyList(); 54 | } 55 | }; 56 | List> brews = brokenResolver.getBrews(); 57 | assertNotNull(brews, "Returned list should not be null even if file is missing"); 58 | assertTrue(brews.isEmpty(), "Returned list should be empty for missing file"); 59 | } 60 | 61 | @Test 62 | void getBrews_returnsEmptyList_whenJsonMalformed() throws Exception { 63 | // Simulate a malformed JSON by mocking ClassPathResource and ObjectMapper 64 | BrewsResolver resolver = Mockito.spy(new BrewsResolver()); 65 | ObjectMapper objectMapperMock = mock(ObjectMapper.class); 66 | doThrow(new IOException("malformed")).when(objectMapperMock).readValue(any(InputStream.class), any(TypeReference.class)); 67 | // Use reflection to inject mock 68 | try { 69 | List> result = resolver.getBrews(); 70 | assertNotNull(result); 71 | } catch (Exception e) { 72 | fail("Should not throw, should return empty list"); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /frontend/src/components/Home.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Container } from 'reactstrap'; 3 | import '.././App.css'; 4 | import AppNavbar from '.././AppNavbar'; 5 | 6 | class Home extends Component { 7 | constructor(props) { 8 | super(props); 9 | const { 10 | className = 'App', 11 | headerClassName = 'App-header', 12 | introId = 'my-intro', 13 | welcomeMessage = 'Welcome to my personal portfolio site.' 14 | } = props; 15 | 16 | this.state = { 17 | className, 18 | headerClassName, 19 | introId, 20 | welcomeMessage 21 | }; 22 | } 23 | 24 | render() { 25 | const { className, headerClassName, introId, welcomeMessage } = this.state; 26 | 27 | return ( 28 |
29 | 30 | 31 |
32 |

33 |

34 | {welcomeMessage}

35 | Please use the navigation bar to view different features such as donate  36 | to one of my preferred charities (select anywhere on tile to go directly to online donation page),  37 | about me, my link tree, a carousel that scrolls 39 | through highlighted projects & the GitHub project manager (PM) tool which is built against the iRonoc API. 40 |

41 | If you like what you see, please sponsor my open source work. 42 |

43 | 46 |

47 | The GitHub PM tool allows you to view & navigate the backlog of issues & bugs for a given project 48 | repository for the corresponding user or organisation account. There is an option to search by user ID 49 | or to drill down to a specific repository name via search or 'List Issues' icon in the 'Actions' column 50 | of the projects view. 51 |

52 | The ironoc API is documented with  Open API 53 |   & sample GET requests that return raw JSON responses are available for demonstration 54 | purposes only i.e.  Issues JSON Sample. 55 |

56 |
57 |
58 |
59 | ); 60 | } 61 | } 62 | 63 | export default Home; 64 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/service/CoffeesService.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import jakarta.annotation.PostConstruct; 6 | import net.ironoc.portfolio.config.PropertyConfigI; 7 | import net.ironoc.portfolio.domain.CoffeeDomain; 8 | import net.ironoc.portfolio.logger.AbstractLogger; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.restclient.RestTemplateBuilder; 11 | import org.springframework.http.ResponseEntity; 12 | import org.springframework.scheduling.annotation.Scheduled; 13 | import org.springframework.stereotype.Service; 14 | import org.springframework.web.client.RestTemplate; 15 | 16 | @Service 17 | public class CoffeesService extends AbstractLogger implements Coffees { 18 | 19 | private final RestTemplate restTemplate; 20 | 21 | private final PropertyConfigI propertyConfig; 22 | 23 | private final CoffeesCache coffeesCache; 24 | 25 | @Autowired 26 | public CoffeesService(RestTemplateBuilder restTemplateBuilder, 27 | PropertyConfigI propertyConfig, 28 | CoffeesCache coffeesCache) { 29 | this.restTemplate = restTemplateBuilder.build(); 30 | this.propertyConfig = propertyConfig; 31 | this.coffeesCache = coffeesCache; 32 | } 33 | 34 | @PostConstruct 35 | public void populateBrewsCache() { 36 | if (propertyConfig.isBrewsCacheJobEnabled()) { 37 | getCoffeeDetails(); 38 | } else { 39 | warn("The job to pre-populate the cache of Coffee Brews information is disabled."); 40 | } 41 | } 42 | 43 | @Scheduled(cron = "${net.ironoc.portfolio.brew.cron-job}") 44 | public void triggerBrewsCacheJob() { 45 | if (propertyConfig.isBrewsCacheJobEnabled()) { 46 | getCoffeeDetails(); 47 | } else { 48 | warn("The job to update the cache of Coffee Brews information is disabled."); 49 | } 50 | } 51 | 52 | @Override 53 | public List getCoffeeDetails() { 54 | coffeesCache.tearDown(); 55 | info("Entering CoffeesService.getCoffeeDetails"); 56 | List hotCoffeeDomains = getBody(propertyConfig.getBrewApiEndpointHot()); 57 | List coffeeDomains = new ArrayList<>(hotCoffeeDomains); 58 | info("Hot Coffee Details: hotCoffeeDtos={}", hotCoffeeDomains); 59 | List icedCoffeeDomains = getBody(propertyConfig.getBrewApiEndpointIce()); 60 | coffeeDomains.addAll(icedCoffeeDomains); 61 | info("Iced Coffee Details: icedCoffeeDtos={}", icedCoffeeDomains); 62 | coffeesCache.put(coffeeDomains); 63 | return coffeeDomains; 64 | } 65 | 66 | private List getBody(String endpoint) { 67 | ResponseEntity response = restTemplate.getForEntity(endpoint, Object.class); 68 | if (response.getStatusCode().is2xxSuccessful()) { 69 | if (response.getBody() instanceof List) { 70 | return (List) response.getBody(); 71 | } else { 72 | error("Error calling coffee API, object type does not match domain POJO: response={}", response); 73 | return Collections.emptyList(); 74 | } 75 | } else { 76 | error("Error calling coffee API: response={}", response); 77 | return Collections.emptyList(); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/controller/BrewGraphqlControllerTest.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.controller; 2 | 3 | import module java.base; 4 | 5 | import net.ironoc.portfolio.dto.Brew; 6 | import net.ironoc.portfolio.graph.BrewsResolver; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.Test; 9 | import org.mockito.InjectMocks; 10 | import org.mockito.Mock; 11 | import org.mockito.MockitoAnnotations; 12 | 13 | import static org.mockito.Mockito.when; 14 | 15 | import static org.junit.jupiter.api.Assertions.assertEquals; 16 | import static org.junit.jupiter.api.Assertions.assertTrue; 17 | import static org.junit.jupiter.api.Assertions.assertArrayEquals; 18 | import static org.junit.jupiter.api.Assertions.assertNull; 19 | import static org.junit.jupiter.api.Assertions.fail; 20 | 21 | class BrewGraphqlControllerTest { 22 | 23 | @Mock 24 | private BrewsResolver brewsResolver; 25 | 26 | @InjectMocks 27 | private BrewGraphqlController controller; 28 | 29 | @BeforeEach 30 | void setUp() { 31 | MockitoAnnotations.openMocks(this); 32 | controller = new BrewGraphqlController(brewsResolver); 33 | } 34 | 35 | @Test 36 | void testBrews_ReturnsCollectionOfMaps() { 37 | Map brew1 = new HashMap<>(); 38 | brew1.put("title", "Coffee1"); 39 | Map brew2 = new HashMap<>(); 40 | brew2.put("title", "Coffee2"); 41 | List> brewsList = Arrays.asList(brew1, brew2); 42 | 43 | when(brewsResolver.getBrews()).thenReturn(brewsList); 44 | 45 | Collection> result = controller.brews(); 46 | assertEquals(2, result.size()); 47 | assertTrue(result.contains(brew1)); 48 | assertTrue(result.contains(brew2)); 49 | } 50 | 51 | @Test 52 | void testBrewsSchemaMapping_ReturnsListOfBrew() { 53 | Map brewMap = new HashMap<>(); 54 | brewMap.put("title", "Espresso"); 55 | brewMap.put("description", "Strong coffee"); 56 | brewMap.put("ingredients", "Coffee, Water"); 57 | brewMap.put("image", "espresso.jpg"); 58 | brewMap.put("id", "10"); 59 | List> brewsList = Collections.singletonList(brewMap); 60 | 61 | when(brewsResolver.getBrews()).thenReturn(brewsList); 62 | 63 | Collection result = controller.brewsSchemaMapping(); 64 | assertEquals(1, result.size()); 65 | Brew brew = result.iterator().next(); 66 | assertEquals("Espresso", brew.getTitle()); 67 | assertEquals("Strong coffee", brew.getDescription()); 68 | assertArrayEquals(new String[] {"Coffee", "Water"}, brew.getIngredients()); 69 | assertEquals("espresso.jpg", brew.getImage()); 70 | assertEquals(10, brew.getId()); 71 | } 72 | 73 | @Test 74 | void testParseValue_NullKeyReturnsNull() { 75 | Map d = new HashMap<>(); 76 | d.put("title", null); 77 | // Use reflection to access private method 78 | try { 79 | java.lang.reflect.Method m = BrewGraphqlController.class.getDeclaredMethod("parseValue", Map.class, String.class); 80 | m.setAccessible(true); 81 | String result = (String) m.invoke(controller, d, "title"); 82 | assertNull(result); 83 | } catch (Exception e) { 84 | fail("Exception during reflection: " + e.getMessage()); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/job/GitDetailsRunnable.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.job; 2 | 3 | import module java.base; 4 | 5 | import net.ironoc.portfolio.config.PropertyConfigI; 6 | import net.ironoc.portfolio.dto.RepositoryIssueDto; 7 | import net.ironoc.portfolio.logger.AbstractLogger; 8 | import net.ironoc.portfolio.service.GitProjectCache; 9 | import net.ironoc.portfolio.service.GitRepoCache; 10 | import net.ironoc.portfolio.dto.RepositoryDetailDto; 11 | import net.ironoc.portfolio.service.GitDetails; 12 | import jakarta.annotation.PreDestroy; 13 | import lombok.Getter; 14 | 15 | @Getter 16 | public class GitDetailsRunnable extends AbstractLogger implements Runnable { 17 | 18 | private final GitRepoCache gitRepoCache; 19 | 20 | private final GitProjectCache gitProjectCache; 21 | 22 | private final GitDetails gitDetails; 23 | 24 | private final PropertyConfigI propertyConfig; 25 | 26 | public final Set userIds; 27 | 28 | public final Set projects; 29 | 30 | public GitDetailsRunnable(GitRepoCache gitRepoCache, 31 | GitProjectCache gitProjectCache, 32 | GitDetails gitDetails, 33 | PropertyConfigI propertyConfig) { 34 | this.gitRepoCache = gitRepoCache; 35 | this.gitProjectCache = gitProjectCache; 36 | this.gitDetails = gitDetails; 37 | this.propertyConfig = propertyConfig; 38 | this.userIds = populateUserIds(); 39 | this.projects = populateProjects(); 40 | } 41 | 42 | protected Set populateUserIds() { 43 | // set user ID list 44 | return new HashSet<>(propertyConfig.getGitApiEndpointUserIdsCache()); 45 | } 46 | 47 | protected Set populateProjects() { 48 | // set project list 49 | return new HashSet<>(propertyConfig.getGitApiEndpointProjectsCache()); 50 | } 51 | 52 | @Override 53 | public void run() { 54 | info("GitDetailsRunnable running for userIds={}", getUserIds()); 55 | 56 | for (String userId : userIds) { 57 | List repositoryDetailDtos = gitDetails.getRepoDetails(userId, true); 58 | info("Running GIT details job for userIds={}, repositoryDetailDtos={}", 59 | userId, repositoryDetailDtos); 60 | if (repositoryDetailDtos != null && !repositoryDetailDtos.isEmpty()) { 61 | gitRepoCache.remove(userId); 62 | gitRepoCache.put(userId, gitDetails.mapRepositoriesToResponse(repositoryDetailDtos)); 63 | 64 | for(String project : getProjects()) { 65 | List issuesDtos = gitDetails.getIssues(userId, project, true); 66 | info("Running GIT details job for userIds={}, project={}, issuesDtos={}", userId, 67 | project, issuesDtos); 68 | if (issuesDtos != null && !issuesDtos.isEmpty()) { 69 | gitProjectCache.remove(userId + project); 70 | gitProjectCache.put(userId, project, gitDetails.mapIssuesToResponse(issuesDtos)); 71 | } 72 | } 73 | } 74 | } 75 | info("GitDetailsRunnable completed for userIds={}", getUserIds()); 76 | } 77 | 78 | @PreDestroy 79 | public void tearDown() { 80 | info("Entering GitDetailsRunnable.tearDown for userIds={}", getUserIds()); 81 | this.userIds.clear(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/resolver/PushStateResourceResolver.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.resolver; 2 | 3 | import module java.base; 4 | 5 | import org.springframework.lang.Nullable; 6 | import net.ironoc.portfolio.logger.AbstractLogger; 7 | import jakarta.servlet.http.HttpServletRequest; 8 | import org.springframework.core.io.ClassPathResource; 9 | import org.springframework.core.io.Resource; 10 | import org.springframework.util.StringUtils; 11 | import org.springframework.web.servlet.resource.ResourceResolver; 12 | import org.springframework.web.servlet.resource.ResourceResolverChain; 13 | 14 | public class PushStateResourceResolver extends AbstractLogger implements ResourceResolver { 15 | 16 | private final Resource index; 17 | 18 | private final List handledExtensions; 19 | 20 | private final List ignoredPaths; 21 | 22 | public PushStateResourceResolver(List handledExtensions, List ignoredPaths) { 23 | this.handledExtensions = handledExtensions; 24 | this.ignoredPaths = ignoredPaths; 25 | this.index = new ClassPathResource("/static/index.html"); 26 | } 27 | 28 | @Override 29 | public Resource resolveResource(@Nullable HttpServletRequest request, @Nullable String requestPath, 30 | @Nullable List locations, @Nullable ResourceResolverChain chain) { 31 | return resolve(requestPath, locations); 32 | } 33 | 34 | @Override 35 | public String resolveUrlPath(@Nullable String resourcePath, @Nullable List locations, 36 | @Nullable ResourceResolverChain chain) { 37 | Resource resolvedResource = resolve(resourcePath, locations); 38 | if (resolvedResource == null) { 39 | return null; 40 | } 41 | try { 42 | return resolvedResource.getURL().toString(); 43 | } catch (IOException e) { 44 | error("Unexpected error occurred resolving URL path.", e); 45 | return resolvedResource.getFilename(); 46 | } 47 | } 48 | 49 | private Resource resolve(String requestPath, List locations) { 50 | if (isIgnored(requestPath)) { 51 | warn("The ignored request path is: {}", requestPath); 52 | return null; 53 | } 54 | if (isHandled(requestPath)) { 55 | Optional staticResource = locations.stream() 56 | .map(loc -> createRelative(loc, requestPath)) 57 | .filter(resource -> resource != null && resource.exists()) 58 | .findFirst(); 59 | debug("The request path is: {}", requestPath); 60 | if (staticResource.isPresent()) { 61 | return staticResource.get(); 62 | } else { 63 | error("The request path {} does not exist.", requestPath); 64 | return null; 65 | } 66 | } 67 | return index; 68 | } 69 | 70 | private Resource createRelative(Resource resource, String relativePath) { 71 | try { 72 | return resource.createRelative(relativePath); 73 | } catch (IOException e) { 74 | error("Unexpected error occurred creating relative path.", e); 75 | return null; 76 | } 77 | } 78 | 79 | private boolean isIgnored(String path) { 80 | return ignoredPaths.contains(path); 81 | } 82 | 83 | private boolean isHandled(String path) { 84 | String extension = StringUtils.getFilenameExtension(path); 85 | return handledExtensions.stream().anyMatch(ext -> ext.equals(extension)); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/service/GitRepoCacheServiceTest.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import com.fasterxml.jackson.databind.ObjectMapper; 6 | import net.ironoc.portfolio.domain.RepositoryDetailDomain; 7 | import org.junit.jupiter.api.Assertions; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | import org.mockito.InjectMocks; 11 | import org.mockito.junit.jupiter.MockitoExtension; 12 | 13 | import static org.hamcrest.MatcherAssert.assertThat; 14 | import static org.hamcrest.Matchers.hasSize; 15 | import static org.hamcrest.Matchers.emptyString; 16 | import static org.hamcrest.Matchers.is; 17 | import static org.hamcrest.Matchers.notNullValue; 18 | import static org.hamcrest.Matchers.nullValue; 19 | 20 | @ExtendWith(MockitoExtension.class) 21 | public class GitRepoCacheServiceTest { 22 | 23 | @InjectMocks 24 | private GitRepoCacheService gitRepoCacheService; 25 | 26 | @Test 27 | public void test_cache_success() throws IOException { 28 | // given 29 | InputStream jsonInputStream = Thread.currentThread().getContextClassLoader() 30 | .getResourceAsStream("json" + File.separator + "test_repo_detail_response.json"); 31 | RepositoryDetailDomain[] dtos = new ObjectMapper().readValue(jsonInputStream, RepositoryDetailDomain[].class); 32 | gitRepoCacheService.put("user1", Collections.emptyList()); 33 | gitRepoCacheService.put("user-2", List.of(dtos)); 34 | 35 | // when 36 | List results = gitRepoCacheService.get("user1"); 37 | List results2 = gitRepoCacheService.get("user-2"); 38 | List results3 = gitRepoCacheService.get("conorheffron-3"); 39 | 40 | // then 41 | assertThat(results, is(notNullValue())); 42 | assertThat(results, is(hasSize(0))); 43 | assertThat(results2, is(hasSize(2))); 44 | Optional result = results2.stream().findFirst(); 45 | assertThat(result.get().getName(), is("bio-cell-red-edge")); 46 | assertThat(result.get().getFullName(), is("conorheffron/bio-cell-red-edge")); 47 | assertThat(result.get().getDescription(), 48 | is("Edge Detection of Biological Cell (Image Processing Script)")); 49 | assertThat(result.get().getTopics(), 50 | is("[Biology, computer-vision, image-processing, scikitlearn-machine-learning]")); 51 | assertThat(result.get().getAppHome(), 52 | is("https://conorheffron.github.io/bio-cell-red-edge/")); 53 | assertThat(result.get().getRepoUrl(), 54 | is("https://github.com/conorheffron/bio-cell-red-edge")); 55 | RepositoryDetailDomain result2 = results2.get(1); 56 | assertThat(result2.getName(), is("booking-sys")); 57 | assertThat(result2.getFullName(), is(nullValue())); 58 | assertThat(result2.getDescription(), is("python3 and django5 web app")); 59 | assertThat(result2.getTopics(), is(emptyString())); 60 | assertThat(result2.getAppHome(), 61 | is("https://booking-sys-ebgefrdmh3afbhee.northeurope-01.azurewebsites.net/book/")); 62 | assertThat(result2.getRepoUrl(), is("https://github.com/conorheffron/booking-sys")); 63 | Assertions.assertNotNull(jsonInputStream); 64 | jsonInputStream.close(); 65 | assertThat(results3, is(nullValue())); 66 | gitRepoCacheService.tearDown(); 67 | assertThat(gitRepoCacheService.get("user1"), is(nullValue())); 68 | assertThat(gitRepoCacheService.get("user-2"), is(nullValue())); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/resources/graphiql/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GraphiQL 7 | 22 | 23 | 24 | 41 | 75 | 76 | 77 |
Loading...
78 | 79 | 80 | -------------------------------------------------------------------------------- /src/test/java/net/ironoc/portfolio/service/PortfolioItemsResolverTest.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import org.junit.jupiter.api.BeforeEach; 6 | import org.junit.jupiter.api.Test; 7 | 8 | import static org.hamcrest.Matchers.is; 9 | import static org.hamcrest.MatcherAssert.assertThat; 10 | 11 | class PortfolioItemsResolverTest { 12 | 13 | private PortfolioItemsResolver portfolioItemsResolver; 14 | 15 | public static List> portfolioItems = List.of( 16 | Map.of( 17 | "link", "https://github.com/conorheffron/ironoc-db", 18 | "img", "navy", 19 | "alt", "navy1", 20 | "title", "ironoc-db", 21 | "description", "Sample Data Manager Service with UI", 22 | "techStack", "Java & Spring Boot, Thymeleaf Templating Engine, & MySQL." 23 | ), 24 | Map.of( 25 | "link", "https://github.com/conorheffron/booking-sys", 26 | "img", "teal", 27 | "alt", "teal2", 28 | "title", "booking-sys", 29 | "description", "Sample Reservations & Viewer System", 30 | "techStack", "Python & Django Web App, JavaScript, SQLite3 or MySQL database." 31 | ), 32 | Map.of( 33 | "link", "https://github.com/conorheffron/nba-stats", 34 | "img", "navy", 35 | "alt", "navy3", 36 | "title", "nba-stats", 37 | "description", "NBA Analytics (Seasons 2015 - 2024): Player Statistics", 38 | "techStack", "Jupyter Notebooks, Python, Pandas, PandasAI, OpenAI GPT-3.5 Turbo, LLM, & Requests / JSON API." 39 | ), 40 | Map.of( 41 | "link", "https://github.com/conorheffron/cbio-skin-canc", 42 | "img", "red", 43 | "alt", "red4", 44 | "title", "cbio-skin-canc", 45 | "description", "Skin Cancer Dataset Analysis", 46 | "techStack", "R, dplyr, plotly, knitr, testthat, covr, GIT." 47 | ), 48 | Map.of( 49 | "link", "https://github.com/conorheffron/gene-expr", 50 | "img", "navy", 51 | "alt", "navy5", 52 | "title", "gene-expr", 53 | "description", "Breast Cancer Dataset Analysis", 54 | "techStack", "R, ggplot2, dplyr, deseq2-analysis, & R markdown." 55 | ), 56 | Map.of( 57 | "link", "https://github.com/conorheffron/bio-cell-red-edge", 58 | "img", "red", 59 | "alt", "red6", 60 | "title", "bio-cell-red-edge", 61 | "description", "Edge Detection of Biological Cell (Image Processing Script)", 62 | "techStack", "Python, sci-kit-image, matplotlib.pyplot, & scipy.ndimage." 63 | ), 64 | Map.of( 65 | "link", "https://github.com/conorheffron/global-max-sim-matrix", 66 | "img", "teal", 67 | "alt", "teal7", 68 | "title", "global-max-sim-matrix", 69 | "description", "Compute Global Maximum Similarity Matrix", 70 | "techStack", "R Package, testthat, stringr, & devtools." 71 | ) 72 | ); 73 | 74 | @BeforeEach 75 | void setUp() { 76 | portfolioItemsResolver = new PortfolioItemsResolver(); 77 | } 78 | 79 | @Test 80 | void testGetPortfolioItems_ValidJson() throws IOException { 81 | // Execute the method 82 | List> actualItems = portfolioItemsResolver.getPortfolioItems(); 83 | 84 | // Assert the results 85 | assertThat(portfolioItems, is(actualItems)); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /frontend/src/components/__tests__/RepoIssues.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen, fireEvent, waitFor } from '@testing-library/react'; 3 | import RepoIssues from '../RepoIssues'; 4 | 5 | // Mock react-router 6 | jest.mock('react-router', () => ({ 7 | useParams: jest.fn(), 8 | useNavigate: jest.fn(), 9 | })); 10 | 11 | // Mock react-bootstrap 12 | jest.mock('react-bootstrap', () => ({ 13 | Container: ({ children, ...props }) =>
{children}
, 14 | InputGroup: ({ children, ...props }) =>
{children}
, 15 | Form: { 16 | Control: ({ ...props }) => , 17 | }, 18 | Button: ({ children, ...props }) => , 19 | })); 20 | 21 | // Mock AppNavbar and LoadingSpinner 22 | jest.mock('../../AppNavbar', () => () =>
Navbar
); 23 | jest.mock('../../LoadingSpinner', () => () =>
Loading...
); 24 | 25 | // Mock MaterialReactTable and useMaterialReactTable 26 | jest.mock('material-react-table', () => ({ 27 | MaterialReactTable: ({ table }) => ( 28 |
29 | {table && table.data && table.data.map((issue, idx) => ( 30 |
{issue.title}
31 | ))} 32 |
33 | ), 34 | useMaterialReactTable: jest.fn((opts) => opts), 35 | })); 36 | 37 | // Mock @mui/material theme functions 38 | jest.mock('@mui/material', () => ({ 39 | useTheme: () => ({ 40 | palette: { 41 | mode: 'light', 42 | secondary: { main: '#00ff00' } 43 | } 44 | }), 45 | darken: (color, amount) => color + '-darken' + amount, 46 | lighten: (color, amount) => color + '-lighten' + amount, 47 | })); 48 | 49 | import { useParams, useNavigate } from 'react-router'; 50 | 51 | describe('RepoIssues', () => { 52 | const mockNavigate = jest.fn(); 53 | 54 | beforeEach(() => { 55 | jest.clearAllMocks(); 56 | useNavigate.mockReturnValue(mockNavigate); 57 | }); 58 | 59 | it('shows loading spinner and navbar initially', () => { 60 | useParams.mockReturnValue({ id: 'user', repo: 'repo' }); 61 | render(); 62 | expect(screen.getByTestId('navbar')).toBeInTheDocument(); 63 | expect(screen.getByTestId('spinner')).toBeInTheDocument(); 64 | }); 65 | 66 | it('fetches and displays issues in the table after loading', async () => { 67 | useParams.mockReturnValue({ id: 'user', repo: 'repo' }); 68 | global.fetch = jest.fn(() => 69 | Promise.resolve({ 70 | json: () => Promise.resolve([ 71 | { 72 | number: 1, 73 | state: 'open', 74 | labels: ['bug'], 75 | title: 'Test Issue', 76 | body: 'Body here', 77 | }, 78 | ]), 79 | }) 80 | ); 81 | render(); 82 | await waitFor(() => 83 | expect(screen.queryByTestId('spinner')).not.toBeInTheDocument() 84 | ); 85 | expect(screen.getByTestId('mrt-table')).toBeInTheDocument(); 86 | }); 87 | 88 | it('navigates on form submit', async () => { 89 | useParams.mockReturnValue({ id: 'user', repo: 'repo' }); 90 | global.fetch = jest.fn(() => Promise.resolve({ json: () => Promise.resolve([]) })); 91 | render(); 92 | await waitFor(() => expect(screen.queryByTestId('spinner')).not.toBeInTheDocument()); 93 | const input = screen.getByTestId('form-control'); 94 | const button = screen.getByText(/Search Issues/i); 95 | fireEvent.change(input, { target: { value: 'newrepo' } }); 96 | fireEvent.click(button); 97 | expect(mockNavigate).toHaveBeenCalledWith('/issues/user/newrepo', { 98 | replace: true, 99 | state: { 100 | id: 'user', 101 | repo: 'newrepo', 102 | }, 103 | }); 104 | expect(mockNavigate).toHaveBeenCalledWith(0); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/config/PropertyConfig.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.config; 2 | 3 | import module java.base; 4 | 5 | import org.apache.commons.lang3.StringUtils; 6 | import org.springframework.core.env.Environment; 7 | import org.springframework.stereotype.Component; 8 | 9 | @Component 10 | public class PropertyConfig implements PropertyConfigI { 11 | 12 | private final Environment environment; 13 | 14 | private final PropertyKeyI propertyKey; 15 | 16 | public PropertyConfig(Environment environment, PropertyKeyI propertyKey) { 17 | this.environment = environment; 18 | this.propertyKey = propertyKey; 19 | } 20 | 21 | @Override 22 | public String getGitApiEndpointRepos() { 23 | return environment.getRequiredProperty(propertyKey.getGitApiEndpointRepos()); 24 | } 25 | 26 | @Override 27 | public String getGitApiEndpointIssues() { 28 | return environment.getRequiredProperty(propertyKey.getGitApiEndpointIssues()); 29 | } 30 | 31 | @Override 32 | public Integer getGitTimeoutConnect() { 33 | return Integer.valueOf(environment.getRequiredProperty(propertyKey.getGitTimeoutConnect())); 34 | } 35 | 36 | @Override 37 | public Integer getGitTimeoutRead() { 38 | return Integer.valueOf(environment.getRequiredProperty(propertyKey.getGitTimeoutRead())); 39 | } 40 | 41 | @Override 42 | public Boolean getGitInstanceFollowRedirects() { 43 | return Boolean.valueOf(environment.getRequiredProperty(propertyKey.getGitInstanceFollowRedirects())); 44 | } 45 | 46 | @Override 47 | public Boolean getGitFollowRedirects() { 48 | return Boolean.valueOf(environment.getRequiredProperty(propertyKey.getGitFollowRedirects())); 49 | } 50 | 51 | @Override 52 | public String getStaticConfIgnorePaths() { 53 | return environment.getRequiredProperty(propertyKey.getStaticConfIgnorePaths()); 54 | } 55 | 56 | @Override 57 | public List getStaticConfHandleExt() { 58 | String handleExt = environment.getRequiredProperty(propertyKey.getStaticConfHandleExt()); 59 | return extractValues(handleExt); 60 | } 61 | 62 | @Override 63 | public String getStaticConfResourceHandler() { 64 | return environment.getRequiredProperty(propertyKey.getStaticConfResourceHandler()); 65 | 66 | } 67 | 68 | @Override 69 | public String getStaticConfResourceLoc() { 70 | return environment.getRequiredProperty(propertyKey.getStaticConfResourceLoc()); 71 | } 72 | 73 | @Override 74 | public List getGitApiEndpointUserIdsCache() { 75 | String userIds = environment.getRequiredProperty(propertyKey.getGitApiEndpointUserIdsCache()); 76 | return extractValues(userIds); 77 | } 78 | 79 | @Override 80 | public List getGitApiEndpointProjectsCache() { 81 | return List.of(environment.getRequiredProperty(propertyKey.getGitApiEndpointProjectsCache(), String[].class)); 82 | } 83 | 84 | @Override 85 | public boolean isCacheJobEnabled() { 86 | return Boolean.parseBoolean(environment.getRequiredProperty(propertyKey.isCacheJobEnabled())); 87 | } 88 | 89 | @Override 90 | public String getBrewApiEndpointHot() { 91 | return environment.getRequiredProperty(propertyKey.getBrewApiEndpointHot()); 92 | } 93 | 94 | @Override 95 | public String getBrewApiEndpointIce() { 96 | return environment.getRequiredProperty(propertyKey.getBrewApiEndpointIce()); 97 | } 98 | 99 | @Override 100 | public String getBrewGraphEndpoint() { 101 | return environment.getRequiredProperty(propertyKey.getBrewGraphEndpoint()); 102 | } 103 | 104 | @Override 105 | public boolean isBrewsCacheJobEnabled() { 106 | return Boolean.parseBoolean(environment.getRequiredProperty(propertyKey.isBrewCacheJobEnabled())); 107 | } 108 | 109 | private List extractValues(String valuesStr) { 110 | return Arrays.stream(StringUtils.split(valuesStr, ",")).toList(); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/main/java/net/ironoc/portfolio/service/GraphQLClientService.java: -------------------------------------------------------------------------------- 1 | package net.ironoc.portfolio.service; 2 | 3 | import module java.base; 4 | 5 | import com.fasterxml.jackson.core.JsonProcessingException; 6 | import com.fasterxml.jackson.databind.ObjectMapper; 7 | import net.ironoc.portfolio.config.PropertyConfigI; 8 | import net.ironoc.portfolio.logger.AbstractLogger; 9 | import org.apache.commons.lang3.StringUtils; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.restclient.RestTemplateBuilder; 12 | import org.springframework.core.io.Resource; 13 | import org.springframework.core.io.ResourceLoader; 14 | import org.springframework.http.HttpHeaders; 15 | import org.springframework.http.HttpMethod; 16 | import org.springframework.http.HttpEntity; 17 | import org.springframework.http.ResponseEntity; 18 | import org.springframework.stereotype.Service; 19 | import org.springframework.web.client.RestTemplate; 20 | 21 | @Service 22 | public class GraphQLClientService extends AbstractLogger implements GraphQLClient { 23 | 24 | private final ObjectMapper objectMapper; 25 | 26 | private final ResourceLoader resourceLoader; 27 | 28 | private final RestTemplate restTemplate; 29 | 30 | private final PropertyConfigI propertyConfig; 31 | 32 | @Autowired 33 | public GraphQLClientService(RestTemplateBuilder restTemplateBuilder, 34 | ObjectMapper objectMapper, 35 | ResourceLoader resourceLoader, 36 | PropertyConfigI propertyConfig) { 37 | this.restTemplate = restTemplateBuilder.build(); 38 | this.objectMapper = objectMapper; 39 | this.resourceLoader = resourceLoader; 40 | this.propertyConfig = propertyConfig; 41 | } 42 | 43 | @Override 44 | public Map fetchCoffeeDetails() throws JsonProcessingException { 45 | HttpHeaders headers = new HttpHeaders(); 46 | headers.set("Content-Type", "application/json"); 47 | 48 | String query = this.loadQuery(); 49 | if (!StringUtils.isBlank(query)) { 50 | String requestPayload = "{ \"query\": \"" + query.replace("\"", "\\\"") 51 | .replace("\n", " ") + "\" }"; 52 | HttpEntity request = new HttpEntity<>(requestPayload, headers); 53 | ResponseEntity response = restTemplate.exchange(propertyConfig.getBrewGraphEndpoint(), 54 | HttpMethod.POST, request, String.class); 55 | return objectMapper.readValue(response.getBody(), Map.class); 56 | } else { 57 | return new HashMap<>(); 58 | } 59 | } 60 | 61 | @Override 62 | public List> getAllIcedCoffees(Map response) { 63 | Map data = getDataFromResponse(response); 64 | if (data != null) { 65 | return (List>) data.get("allIceds"); 66 | } 67 | warn("No response data found during getAllIcedCoffees for response, response={}", response); 68 | return null; 69 | } 70 | 71 | @Override 72 | public List> getAllHotCoffees(Map response) { 73 | Map data = getDataFromResponse(response); 74 | if (data != null) { 75 | return (List>) data.get("allHots"); 76 | } 77 | warn("No response data found during getAllHotCoffees for response, response={}", response); 78 | return null; 79 | } 80 | 81 | String loadQuery() { 82 | Resource resource = resourceLoader.getResource("classpath:graphql" + File.separator + "coffeesQuery.graphqls"); 83 | try { 84 | return new String(Files.readAllBytes(Paths.get(resource.getURI()))); 85 | } catch (IOException e) { 86 | error("Unexpected exception occurred loading GraphQL query, msg={}", e.getMessage()); 87 | } 88 | return null; 89 | } 90 | 91 | private static Map getDataFromResponse(Map response) { 92 | return (Map) response.get("data"); 93 | } 94 | } -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | schedule: 10 | - cron: '45 20 * * *' 11 | push: 12 | branches: [ "main" ] 13 | # Publish semver tags as releases. 14 | tags: [ 'v*.*.*' ] 15 | pull_request: 16 | branches: [ "main" ] 17 | 18 | env: 19 | # Use docker.io for Docker Hub if empty 20 | REGISTRY: ghcr.io 21 | # github.repository as / 22 | IMAGE_NAME: ${{ github.repository }} 23 | 24 | 25 | jobs: 26 | build: 27 | 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: read 31 | packages: write 32 | # This is used to complete the identity challenge 33 | # with sigstore/fulcio when running outside of PRs. 34 | id-token: write 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v4 39 | 40 | # Install the cosign tool except on PR 41 | # https://github.com/sigstore/cosign-installer 42 | - name: Install cosign 43 | if: github.event_name != 'pull_request' 44 | uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 45 | with: 46 | cosign-release: 'v2.2.4' 47 | 48 | # Set up BuildKit Docker container builder to be able to build 49 | # multi-platform images and export cache 50 | # https://github.com/docker/setup-buildx-action 51 | - name: Set up Docker Buildx 52 | uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 53 | 54 | # Login against a Docker registry except on PR 55 | # https://github.com/docker/login-action 56 | - name: Log into registry ${{ env.REGISTRY }} 57 | if: github.event_name != 'pull_request' 58 | uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 59 | with: 60 | registry: ${{ env.REGISTRY }} 61 | username: ${{ github.actor }} 62 | password: ${{ secrets.GITHUB_TOKEN }} 63 | 64 | # Extract metadata (tags, labels) for Docker 65 | # https://github.com/docker/metadata-action 66 | - name: Extract Docker metadata 67 | id: meta 68 | uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 69 | with: 70 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 71 | 72 | # Build and push Docker image with Buildx (don't push on PR) 73 | # https://github.com/docker/build-push-action 74 | - name: Build and push Docker image 75 | id: build-and-push 76 | uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 77 | with: 78 | context: . 79 | file: publish/Dockerfile 80 | push: ${{ github.event_name != 'pull_request' }} 81 | tags: ${{ steps.meta.outputs.tags }} 82 | labels: ${{ steps.meta.outputs.labels }} 83 | cache-from: type=gha 84 | cache-to: type=gha,mode=max 85 | 86 | # Sign the resulting Docker image digest except on PRs. 87 | # This will only write to the public Rekor transparency log when the Docker 88 | # repository is public to avoid leaking data. If you would like to publish 89 | # transparency data even for private images, pass --force to cosign below. 90 | # https://github.com/sigstore/cosign 91 | - name: Sign the published Docker image 92 | if: ${{ github.event_name != 'pull_request' }} 93 | env: 94 | # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable 95 | TAGS: ${{ steps.meta.outputs.tags }} 96 | DIGEST: ${{ steps.build-and-push.outputs.digest }} 97 | # This step uses the identity token to provision an ephemeral certificate 98 | # against the sigstore community Fulcio instance. 99 | run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} 100 | -------------------------------------------------------------------------------- /frontend/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen, waitFor } from '@testing-library/react'; 3 | import '@testing-library/jest-dom'; 4 | import Home from './components/Home'; 5 | import App from './App'; 6 | import { createMemoryHistory } from 'history'; 7 | import LoadingSpinner from './LoadingSpinner'; 8 | import AppNavBar from './AppNavbar'; 9 | import About from './components/About'; 10 | import Footer from './Footer'; 11 | import { Router, useLocation, MemoryRouter } from 'react-router'; 12 | 13 | describe('AppNavBar', () => { 14 | test('renders AppNavBar component correctly', () => { 15 | // Render the component 16 | render(); 17 | 18 | // Check if the Navbar is present 19 | const navbar = screen.getByRole('navigation'); 20 | expect(navbar).toBeInTheDocument(); 21 | 22 | // Check if the logo image is present 23 | const logo = screen.getByAltText(''); 24 | expect(logo).toBeInTheDocument(); 25 | expect(logo).toHaveAttribute('src', 'robot-logo.png'); 26 | 27 | // Check if the "Brews" dropdown is present 28 | const brewsDropdown = screen.getByText('Brews'); 29 | expect(brewsDropdown).toBeInTheDocument(); 30 | 31 | // Check if the "Portfolio" dropdown is present 32 | const portfolioDropdown = screen.getByText('Portfolio'); 33 | expect(portfolioDropdown).toBeInTheDocument(); 34 | 35 | // Check if the "About" dropdown is present 36 | const aboutDropdown = screen.getByText('About'); 37 | expect(aboutDropdown).toBeInTheDocument(); 38 | 39 | // Check if the "GitHub PM" dropdown is present 40 | const githubPMDropdown = screen.getByText('GitHub PM'); 41 | expect(githubPMDropdown).toBeInTheDocument(); 42 | 43 | // Check if the "GitHub API" dropdown is present 44 | const githubAPIDropdown = screen.getByText('GitHub API'); 45 | expect(githubAPIDropdown).toBeInTheDocument(); 46 | 47 | // Check if the "GitHub Projects" dropdown is present 48 | const githubProjectsDropdown = screen.getByText('GitHub Projects'); 49 | expect(githubProjectsDropdown).toBeInTheDocument(); 50 | 51 | // Check if the "Charity Options" dropdown is present 52 | const donateDropdown = screen.getByText('Charity Options'); 53 | expect(donateDropdown).toBeInTheDocument(); 54 | 55 | // Check if the "Donate" dropdown is present 56 | const donate = screen.getByText('Donate'); 57 | expect(donate).toBeInTheDocument(); 58 | 59 | // Check if the "GraphQL PG" dropdown is present 60 | const graphQLDropdown = screen.getByText('GraphQL PG'); 61 | expect(graphQLDropdown).toBeInTheDocument(); 62 | 63 | // Check if the "Home" link is present 64 | const homeLink = screen.getByText('Home'); 65 | expect(homeLink).toBeInTheDocument(); 66 | expect(homeLink.closest('a')).toHaveAttribute('href', '/'); 67 | }); 68 | }); 69 | 70 | describe('Footer Component', () => { 71 | beforeEach(() => { 72 | // Mock the fetch API 73 | global.fetch = jest.fn(() => 74 | Promise.resolve({ 75 | ok: true, 76 | text: () => Promise.resolve('2.2.0'), 77 | }) 78 | ); 79 | }); 80 | 81 | afterEach(() => { 82 | fetch.mockClear(); 83 | }); 84 | 85 | test('renders without crashing', () => { 86 | render(