diff --git a/.env b/.env new file mode 100644 index 0000000..91e32a9 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +PG_USER=postgres +PG_PASSWORD=postgres +PG_HOST=localhost +PORT=8080 \ No newline at end of file diff --git a/.github/workflows/cloud-run-action.yml b/.github/workflows/cloud-run-action.yml new file mode 100644 index 0000000..f2a1e83 --- /dev/null +++ b/.github/workflows/cloud-run-action.yml @@ -0,0 +1,123 @@ +name: Java CI/CD Pipeline + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "21" + cache: "gradle" + + - name: Cache Gradle dependencies + uses: actions/cache@v4 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Make gradlew executable + run: chmod +x ./gradlew + - name: Build with Gradle + run: | + ./gradlew assemble + # (Optional) Add steps for running tests and generating reports + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: java-app + path: build/libs/*.jar + + test: + name: Test + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "21" + cache: "gradle" + - name: Make gradlew executable + run: chmod +x ./gradlew + - name: Cache Gradle dependencies + uses: actions/cache@v4 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Test with Gradle + run: | + ./gradlew check --info --stacktrace + ./gradlew test + ./gradlew jacocoTestReport + # (Optional) Add steps for generating coverage report and other post-test tasks + + publish: + name: Publish Docker Image + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + - name: check directory + run: ls -al + - name: Download Artifact + uses: actions/download-artifact@v4 + with: + name: java-app + - name: check directory + run: ls -al + - name: Login to Docker Hub + run: echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.REGISTRY_USER }} --password-stdin docker.io + - name: Set Docker BuildKit + run: export DOCKER_BUILDKIT=1 + - name: Build Docker Image + run: | + docker build --build-arg PRODUCTION=${{ secrets.PRODUCTION }} --build-arg JDBC_DATABASE_PASSWORD=${{ secrets.JDBC_DATABASE_PASSWORD }} --build-arg JDBC_DATABASE_URL=${{ secrets.JDBC_DATABASE_URL }} --build-arg JDBC_DATABASE_USERNAME=${{ secrets.JDBC_DATABASE_USERNAME }} -t ${{ secrets.REGISTRY_USER }}/${{ secrets.IMAGE_NAME }}:${{ secrets.IMAGE_TAG }} . + docker push ${{ secrets.REGISTRY_USER }}/${{ secrets.IMAGE_NAME }}:${{ secrets.IMAGE_TAG }} + + + + deploy: + name: Deploy to GCP + runs-on: ubuntu-latest + needs: publish + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install SSH client + run: sudo apt-get install openssh-client + + - name: create ssh key + run: echo "${{ secrets.SSH_KEY }}" > ssh-key.pem + + - name: update permission ssh key + run: chmod 400 ssh-key.pem + + - name: Deploy to GCP + run: | + ssh -o StrictHostKeyChecking=no -i ssh-key.pem ${{ secrets.GCP_USERNAME }}@${{ secrets.GCP_STATIC_IP }} " + sudo docker container rm -f ${{ secrets.CONTAINER_NAME }} || true && + sudo docker image rm -f ${{ secrets.REGISTRY_USER }}/${{ secrets.IMAGE_NAME }}:${{ secrets.IMAGE_TAG }} || true && + sudo docker run --name ${{ secrets.CONTAINER_NAME }} -d -p 80:8080 ${{ secrets.REGISTRY_USER }}/${{ secrets.IMAGE_NAME }}:${{ secrets.IMAGE_TAG }}" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..db9b952 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM gradle:jdk21-alpine +ARG PRODUCTION +ARG JDBC_DATABASE_PASSWORD +ARG JDBC_DATABASE_URL +ARG JDBC_DATABASE_USERNAME + +ENV PRODUCTION ${PRODUCTION} +ENV JDBC_DATABASE_PASSWORD ${JDBC_DATABASE_PASSWORD} +ENV JDBC_DATABASE_URL ${JDBC_DATABASE_URL} +ENV JDBC_DATABASE_USERNAME ${JDBC_DATABASE_USERNAME} + +WORKDIR /app +COPY ./review-0.0.1-SNAPSHOT.jar /app +RUN ls -la +EXPOSE 8080 +CMD ["java","-jar","review-0.0.1-SNAPSHOT.jar"] \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 0de0729..2a83950 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { java id("org.springframework.boot") version "3.2.5" id("io.spring.dependency-management") version "1.1.4" + jacoco } group = "snackscription" @@ -34,3 +35,19 @@ dependencies { tasks.withType { useJUnitPlatform() } + +tasks.test { + useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) +} +tasks.jacocoTestReport { + classDirectories.setFrom(files(classDirectories.files.map { + fileTree(it) { exclude("**/*Application**")} + })) + dependsOn(tasks.test) + reports { + xml.required.set(false) + csv.required.set(false) + html.outputLocation.set(layout.buildDirectory.dir("jacocoHtml")) + } +} \ No newline at end of file diff --git a/src/main/java/snackscription/review/ReviewApplication.java b/src/main/java/snackscription/review/ReviewApplication.java index f9e2818..ec64e1c 100644 --- a/src/main/java/snackscription/review/ReviewApplication.java +++ b/src/main/java/snackscription/review/ReviewApplication.java @@ -8,6 +8,8 @@ public class ReviewApplication { public static void main(String[] args) { SpringApplication.run(ReviewApplication.class, args); + + } } diff --git a/src/main/java/snackscription/review/controller/ReviewController.java b/src/main/java/snackscription/review/controller/ReviewController.java new file mode 100644 index 0000000..7abe48b --- /dev/null +++ b/src/main/java/snackscription/review/controller/ReviewController.java @@ -0,0 +1,15 @@ +package snackscription.review.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/") +public class ReviewController { + @GetMapping("") + public ResponseEntity reviewPage() { + return ResponseEntity.ok().body("Welcome to the review service!"); + } +} \ No newline at end of file diff --git a/src/main/java/snackscription/review/model/Review.java b/src/main/java/snackscription/review/model/Review.java new file mode 100644 index 0000000..c05c8b9 --- /dev/null +++ b/src/main/java/snackscription/review/model/Review.java @@ -0,0 +1,39 @@ +package snackscription.review.model; + +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +public class Review { + private final String id; + private int rating; + @Setter + private String content; + @Setter + private ReviewState state; + private final String userId; + private final String subscriptionBoxId; + + public Review(int rating, String content, String userId, String subscriptionBoxId) { + this.id = UUID.randomUUID().toString(); + this.rating = rating; + this.content = content; + this.state = new ReviewStatePending(this); + this.userId = userId; + this.subscriptionBoxId = subscriptionBoxId; + } + + public void editReview(int rating, String content) { + this.setRating(rating); + this.setContent(content); + } + + public void setRating(int rating) { + if (rating < 0 || rating > 5) { + throw new RuntimeException("Rating should be between 0 and 5."); + } + this.rating = rating; + } +} \ No newline at end of file diff --git a/src/main/java/snackscription/review/model/ReviewState.java b/src/main/java/snackscription/review/model/ReviewState.java new file mode 100644 index 0000000..b6d5daa --- /dev/null +++ b/src/main/java/snackscription/review/model/ReviewState.java @@ -0,0 +1,13 @@ +package snackscription.review.model; + +public abstract class ReviewState { + Review review; + String state; + ReviewState(Review review) { + this.review = review; + } + + public abstract void approve(); + + public abstract void reject(); +} \ No newline at end of file diff --git a/src/main/java/snackscription/review/model/ReviewStateApproved.java b/src/main/java/snackscription/review/model/ReviewStateApproved.java new file mode 100644 index 0000000..3875665 --- /dev/null +++ b/src/main/java/snackscription/review/model/ReviewStateApproved.java @@ -0,0 +1,23 @@ +package snackscription.review.model; + +public class ReviewStateApproved extends ReviewState { + + ReviewStateApproved(Review review) { + super(review); + } + + @Override + public void approve() { + throw new RuntimeException("Review already approved."); + } + + @Override + public void reject() { + this.review.setState(new ReviewStateRejected(this.review)); + } + + @Override + public String toString() { + return "Approved"; + } +} \ No newline at end of file diff --git a/src/main/java/snackscription/review/model/ReviewStatePending.java b/src/main/java/snackscription/review/model/ReviewStatePending.java new file mode 100644 index 0000000..0064775 --- /dev/null +++ b/src/main/java/snackscription/review/model/ReviewStatePending.java @@ -0,0 +1,23 @@ +package snackscription.review.model; + +public class ReviewStatePending extends ReviewState { + + ReviewStatePending(Review review) { + super(review); + } + + @Override + public void approve() { + this.review.setState(new ReviewStateApproved(this.review)); + } + + @Override + public void reject() { + this.review.setState(new ReviewStateRejected(this.review)); + } + + @Override + public String toString() { + return "Pending"; + } +} \ No newline at end of file diff --git a/src/main/java/snackscription/review/model/ReviewStateRejected.java b/src/main/java/snackscription/review/model/ReviewStateRejected.java new file mode 100644 index 0000000..68df2cd --- /dev/null +++ b/src/main/java/snackscription/review/model/ReviewStateRejected.java @@ -0,0 +1,22 @@ +package snackscription.review.model; + +public class ReviewStateRejected extends ReviewState { + ReviewStateRejected(Review review) { + super(review); + } + + @Override + public void approve() { + this.review.setState(new ReviewStateApproved(this.review)); + } + + @Override + public void reject() { + throw new RuntimeException("Review already rejected."); + } + + @Override + public String toString() { + return "Rejected"; + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..4983766 --- /dev/null +++ b/src/main/resources/application-dev.properties @@ -0,0 +1,7 @@ +spring.datasource.url=jdbc:postgresql://aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres +spring.datasource.username=postgres +spring.datasource.password=postgres +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.properties.hibernate.format_sql=true \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..2dd3dab --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,6 @@ +spring.datasource.url=${JDBC_DATABASE_URL} +spring.datasource.username=${JDBC_DATABASE_USERNAME} +spring.datasource.password=${JDBC_DATABASE_PASSWORD} +spring.jpa.hibernate.ddl-auto=update +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.properties.hibernate.format_sql=true \ No newline at end of file diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties new file mode 100644 index 0000000..9b629dc --- /dev/null +++ b/src/main/resources/application-test.properties @@ -0,0 +1,7 @@ +spring.datasource.url=jdbc:postgresql://aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres +spring.datasource.username=postgres +spring.datasource.password=postgres +spring.jpa.hibernate.ddl-auto=create +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.properties.hibernate.format_sql=true \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index fd7c01a..338aaa3 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,2 @@ spring.application.name=review +spring.profiles.active=${PRODUCTION:dev} \ No newline at end of file diff --git a/src/test/java/snackscription/review/model/ReviewTest.java b/src/test/java/snackscription/review/model/ReviewTest.java new file mode 100644 index 0000000..74815f8 --- /dev/null +++ b/src/test/java/snackscription/review/model/ReviewTest.java @@ -0,0 +1,95 @@ +package snackscription.review.model; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class ReviewTest { + Review review; + + @BeforeEach + void setUp() { + this.review = new Review( + 5, + "Bagus bgt dah", + "1", + "111" + ); + } + + @Test + void testCreateReview() { + int rating = 3; + String content = "Wow!"; + String userId = "33"; + String subscriptionBoxId = "12345"; + + Review newReview = new Review(rating, content, userId, subscriptionBoxId); + assertEquals(rating, newReview.getRating()); + assertEquals(content, newReview.getContent()); + assertEquals(userId, newReview.getUserId()); + assertEquals(subscriptionBoxId, newReview.getSubscriptionBoxId()); + assertNotNull(newReview.getId()); + assertEquals("Pending", newReview.getState().toString()); + } + + @Test + void testEditReview() { + int newRating = 1; + String newContent = "jelek"; + this.review.editReview(newRating, newContent); + assertEquals(newRating, this.review.getRating()); + assertEquals(newContent, this.review.getContent()); + assertEquals("Pending", this.review.getState().toString()); + } + + @Test + void testEditReviewInvalid() { + int newRating = -1; + String newContent = "jelek"; + assertThrows(RuntimeException.class, () -> this.review.editReview(newRating, newContent)); + } + + @Test + void testApprovePendingReview() { + this.review.setState(new ReviewStatePending(this.review)); + this.review.getState().approve(); + assertEquals("Approved", this.review.getState().toString()); + } + + @Test + void testRejectPendingReview() { + this.review.setState(new ReviewStatePending(this.review)); + this.review.getState().reject(); + assertEquals("Rejected", this.review.getState().toString()); + } + + @Test + void testApproveApprovedReview() { + this.review.setState(new ReviewStateApproved(this.review)); + assertThrows(RuntimeException.class, () -> this.review.getState().approve()); + assertEquals("Approved", this.review.getState().toString()); + } + + @Test + void testRejectApprovedReview() { + this.review.setState(new ReviewStateApproved(this.review)); + this.review.getState().reject(); + assertEquals("Rejected", this.review.getState().toString()); + } + + @Test + void testApproveRejectedReview() { + this.review.setState(new ReviewStateRejected(this.review)); + this.review.getState().approve(); + assertEquals("Approved", this.review.getState().toString()); + } + + @Test + void testRejectRejectedReview() { + this.review.setState(new ReviewStateRejected(this.review)); + assertThrows(RuntimeException.class, () -> this.review.getState().reject()); + assertEquals("Rejected", this.review.getState().toString()); + } +} \ No newline at end of file