diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d12b63f --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Operating System Files + +*.DS_Store +Thumbs.db +*.sw? +.#* +*# +*~ +*.sublime-* + +# Build Artifacts + +.gradle/ +build/ +target/ +bin/ +out/ +dependency-reduced-pom.xml + +# Eclipse Project Files + +.classpath +.project +.metadata +.loadpath +bin/ +.settings/ + +# IntelliJ IDEA Files + +*.iml +*.ipr +*.iws +*.idea + +.vscode/ +README.html + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ca5ecb --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Core Spring and Spring Boot Lab Projects + +Labs for the Core Spring and Spring Boot courses + +To import these labs into your IDE, import the parent pom `lab/pom.xml` as Maven projects or `lab/build.gradle` as Gradle projects. diff --git a/lab/.mvn/wrapper/maven-wrapper.properties b/lab/.mvn/wrapper/maven-wrapper.properties new file mode 100755 index 0000000..48a56c9 --- /dev/null +++ b/lab/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip diff --git a/lab/00-rewards-common/build.gradle b/lab/00-rewards-common/build.gradle new file mode 100644 index 0000000..38c7275 --- /dev/null +++ b/lab/00-rewards-common/build.gradle @@ -0,0 +1,6 @@ +apply plugin: 'java-library' + +dependencies { + api "org.hibernate.orm:hibernate-core" + api "com.fasterxml.jackson.core:jackson-annotations" +} diff --git a/lab/00-rewards-common/pom.xml b/lab/00-rewards-common/pom.xml new file mode 100644 index 0000000..e93fdd9 --- /dev/null +++ b/lab/00-rewards-common/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + 00-rewards-common + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + com.fasterxml.jackson.core + jackson-annotations + + + org.hibernate.orm + hibernate-core + + + diff --git a/lab/00-rewards-common/src/main/java/common/datetime/DateInterval.java b/lab/00-rewards-common/src/main/java/common/datetime/DateInterval.java new file mode 100644 index 0000000..bab4956 --- /dev/null +++ b/lab/00-rewards-common/src/main/java/common/datetime/DateInterval.java @@ -0,0 +1,21 @@ +package common.datetime; + +public class DateInterval { + + private SimpleDate start; + + private SimpleDate end; + + public DateInterval(SimpleDate start, SimpleDate end) { + this.start = start; + this.end = end; + } + + public SimpleDate getStart() { + return start; + } + + public SimpleDate getEnd() { + return end; + } +} diff --git a/lab/00-rewards-common/src/main/java/common/datetime/SimpleDate.java b/lab/00-rewards-common/src/main/java/common/datetime/SimpleDate.java new file mode 100644 index 0000000..b2a1926 --- /dev/null +++ b/lab/00-rewards-common/src/main/java/common/datetime/SimpleDate.java @@ -0,0 +1,112 @@ +package common.datetime; + +import java.io.Serializable; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.text.SimpleDateFormat; + +/** + * A simple wrapper around a calendar for working with dates like 12/29/1977. Does not consider time. + */ +public class SimpleDate implements Serializable { + + private static final long serialVersionUID = 2285962420279644602L; + + private GregorianCalendar base; + + /** + * Create a new simple date. + * @param month the month + * @param day the day + * @param year the year + */ + public SimpleDate(int month, int day, int year) { + init(new GregorianCalendar(year, month - 1, day)); + } + + SimpleDate(long time) { + GregorianCalendar cal = new GregorianCalendar(); + cal.setTimeInMillis(time); + init(cal); + } + + private SimpleDate() { + init(new GregorianCalendar()); + } + + private void init(GregorianCalendar cal) { + this.base = trimToDays(cal); + } + + private GregorianCalendar trimToDays(GregorianCalendar cal) { + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + return cal; + } + + /** + * Returns this simple date as a java.util.Date + * @return this simple date as a Date + */ + public Date asDate() { + return base.getTime(); + } + + /** + * Returns this date in milliseconds since 1970. + * @return + */ + public long inMilliseconds() { + return asDate().getTime(); + } + + public int compareTo(Object date) { + SimpleDate other = (SimpleDate) date; + return asDate().compareTo(other.asDate()); + } + + public boolean equals(Object day) { + if (!(day instanceof SimpleDate other)) { + return false; + } + return (base.equals(other.base)); + } + + public int hashCode() { + return 29 * base.hashCode(); + } + + /** + * Returns todays date. A convenient static factory method. + */ + public static SimpleDate today() { + return new SimpleDate(); + } + + /** + * Converts the specified date to a SimpleDate. Will trim hour, minute, second, and millisecond fields. + * @param date the java.util.Date + * @return the simple date + */ + public static SimpleDate valueOf(Date date) { + return valueOf(date.getTime()); + } + + /** + * Converts the specified long time value to a SimpleDate. Will trim hour, minute, second, and millisecond fields. + * @param time time in millseconds since 1970 + * @return the time as a SimpleDate + */ + public static SimpleDate valueOf(long time) { + return new SimpleDate(time); + } + + @Override + public String toString() { + return new SimpleDateFormat().format(base.getTime()); + } + +} \ No newline at end of file diff --git a/lab/00-rewards-common/src/main/java/common/datetime/SimpleDateEditor.java b/lab/00-rewards-common/src/main/java/common/datetime/SimpleDateEditor.java new file mode 100644 index 0000000..dc1b438 --- /dev/null +++ b/lab/00-rewards-common/src/main/java/common/datetime/SimpleDateEditor.java @@ -0,0 +1,34 @@ +package common.datetime; + +import java.beans.PropertyEditorSupport; +import java.text.DateFormat; +import java.text.ParseException; +import java.util.Locale; + +/** + * A formatter for Simple date properties. Converts object values to well-formatted strings and strings back to + * values. Usable by a data binding framework for binding user input to the model. + */ +public class SimpleDateEditor extends PropertyEditorSupport { + + private final DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.LONG, Locale.ENGLISH); + + @Override + public String getAsText() { + SimpleDate date = (SimpleDate) getValue(); + if (date == null) { + return ""; + } else { + return dateFormat.format(date.asDate()); + } + } + + @Override + public void setAsText(String text) throws IllegalArgumentException { + try { + setValue(SimpleDate.valueOf(dateFormat.parse(text))); + } catch (ParseException e) { + throw new IllegalArgumentException("Unable to convert String '" + text + "' to a SimpleDate", e); + } + } +} \ No newline at end of file diff --git a/lab/00-rewards-common/src/main/java/common/datetime/package.html b/lab/00-rewards-common/src/main/java/common/datetime/package.html new file mode 100644 index 0000000..d3412af --- /dev/null +++ b/lab/00-rewards-common/src/main/java/common/datetime/package.html @@ -0,0 +1,7 @@ + + +

+Shared classes for representing date and time. +

+ + diff --git a/lab/00-rewards-common/src/main/java/common/money/MonetaryAmount.java b/lab/00-rewards-common/src/main/java/common/money/MonetaryAmount.java new file mode 100644 index 0000000..fe7c660 --- /dev/null +++ b/lab/00-rewards-common/src/main/java/common/money/MonetaryAmount.java @@ -0,0 +1,167 @@ +package common.money; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * A representation of money. + * + * A value object. Immutable. + */ +@Embeddable +public class MonetaryAmount implements Serializable { + + private static final long serialVersionUID = -3734467432803577280L; + + private BigDecimal value; + + /** + * Create a new monetary amount from the specified value. + * @param value the value of the amount; for example, in $USD "10.00" would be ten dollars, ".29" would be 29 cents + */ + @JsonCreator + public MonetaryAmount(BigDecimal value) { + initValue(value); + } + + /** + * Create a new monetary amount from the specified value. + * @param value the monetary amount as a double + */ + public MonetaryAmount(double value) { + initValue(BigDecimal.valueOf(value)); + } + + @SuppressWarnings("unused") + private MonetaryAmount() { + } + + private void initValue(BigDecimal value) { + this.value = value.setScale(2, RoundingMode.HALF_EVEN); + } + + /** + * Convert the string representation of a monetary amount (e.g. $5 or 5) to a MonetaryAmount object. + * @param string the monetary amount string + * @return the monetary amount object + */ + public static MonetaryAmount valueOf(String string) { + if (string == null || string.length() == 0) { + throw new IllegalArgumentException("The monetary amount value is required"); + } + if (string.startsWith("$")) { + int index = string.indexOf('$'); + string = string.substring(index + 1); + } + BigDecimal value = new BigDecimal(string); + return new MonetaryAmount(value); + } + + /** + * Returns the zero (0.00) monetary amount. + */ + public static MonetaryAmount zero() { + return new MonetaryAmount(0); + } + + /** + * Add to this monetary amount, returning the sum as a new monetary amount. + * @param amount the amount to add + * @return the sum + */ + public MonetaryAmount add(MonetaryAmount amount) { + return new MonetaryAmount(value.add(amount.value)); + } + + /** + * Subtract from this monetary amount, returning the difference as a new monetary amount. + * @param amount the amount to subtract + * @return the difference + */ + public MonetaryAmount subtract(MonetaryAmount amount) { + return new MonetaryAmount(value.subtract(amount.value)); + } + + /** + * Multiply this monetary amount, returning the product as a new monetary amount. + * @param amount the amount to multiply + * @return the product + */ + public MonetaryAmount multiplyBy(BigDecimal amount) { + return new MonetaryAmount(value.multiply(amount)); + } + + /** + * Divide this monetary amount, returning the quotient as a decimal. + * @param amount the amount to divide by + * @return the quotient + */ + public BigDecimal divide(MonetaryAmount amount) { + return value.divide(amount.value); + } + + /** + * Divide this monetary amount, returning the quotient as a new monetary amount. + * @param amount the amount to divide by + * @return the quotient + */ + public MonetaryAmount divideBy(BigDecimal amount) { + return new MonetaryAmount(value.divide(amount)); + } + + /** + * Multiply this monetary amount by a percentage. + * @param percentage the percentage + * @return the percentage amount + */ + public MonetaryAmount multiplyBy(Percentage percentage) { + return new MonetaryAmount(value.multiply(percentage.asBigDecimal())); + } + + /** + * Returns true if this amount is greater than the amount. + * @param amount the monetary amount + * @return true or false + */ + public boolean greaterThan(MonetaryAmount amount) { + return value.compareTo(amount.value) > 0; + } + + /** + * Get this amount as a double. Useful for when a double type is needed by an external API or system. + * @return this amount as a double + */ + public double asDouble() { + return value.doubleValue(); + } + + /** + * Get this amount as a big decimal. Useful for when a BigDecimal type is needed by an external API or system. + * @return this amount as a big decimal + */ + @JsonValue + public BigDecimal asBigDecimal() { + return value; + } + + public boolean equals(Object o) { + if (!(o instanceof MonetaryAmount)) { + return false; + } + return value.equals(((MonetaryAmount) o).value); + } + + public int hashCode() { + return value.hashCode(); + } + + public String toString() { + return "$" + value.toString(); + } + +} \ No newline at end of file diff --git a/lab/00-rewards-common/src/main/java/common/money/MonetaryAmountEditor.java b/lab/00-rewards-common/src/main/java/common/money/MonetaryAmountEditor.java new file mode 100644 index 0000000..c50d045 --- /dev/null +++ b/lab/00-rewards-common/src/main/java/common/money/MonetaryAmountEditor.java @@ -0,0 +1,33 @@ +package common.money; + +import java.beans.PropertyEditorSupport; + +import org.springframework.util.StringUtils; + +/** + * A formatter for Monetary amount properties. Converts object values to well-formatted strings and strings back to + * values. Usable by a data binding framework for binding user input to the model. + */ +public class MonetaryAmountEditor extends PropertyEditorSupport { + + @Override + public String getAsText() { + MonetaryAmount amount = (MonetaryAmount) getValue(); + if (amount == null) { + return ""; + } else { + return amount.toString(); + } + } + + @Override + public void setAsText(String text) throws IllegalArgumentException { + if (StringUtils.hasText(text)) { + setValue(MonetaryAmount.valueOf(text)); + } else { + setValue(null); + } + } + + +} diff --git a/lab/00-rewards-common/src/main/java/common/money/Percentage.java b/lab/00-rewards-common/src/main/java/common/money/Percentage.java new file mode 100644 index 0000000..ee305a0 --- /dev/null +++ b/lab/00-rewards-common/src/main/java/common/money/Percentage.java @@ -0,0 +1,135 @@ +package common.money; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import jakarta.persistence.Embeddable; +import java.io.Serializable; +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * A percentage. Represented as a decimal value with scale 2 between 0.00 and 1.00. + * + * A value object. Immutable. + */ +@Embeddable +public class Percentage implements Serializable { + + private static final long serialVersionUID = 8077279865855620752L; + + private BigDecimal value; + + /** + * Create a new percentage from the specified value. Value must be between 0 and 1. For example, value .45 + * represents 45%. If the value has more than two digits past the decimal point it will be rounded up. For example, + * value .24555 rounds up to .25. + * @param the percentage value + * @throws IllegalArgumentException if the value is not between 0 and 1 + */ + @JsonCreator + public Percentage(BigDecimal value) { + initValue(value); + } + + /** + * Create a new percentage from the specified double value. Converts it to a BigDecimal with exact precision. Value + * must be between 0 and 1. For example, value .45 represents 45%. If the value has more than two digits past the + * decimal point it will be rounded up. For example, value .24555 rounds up to .25. + * @param the percentage value as a double + * @throws IllegalArgumentException if the value is not between 0 and 1 + */ + public Percentage(double value) { + initValue(BigDecimal.valueOf(value)); + } + + @SuppressWarnings("unused") + private Percentage() { + } + + private void initValue(BigDecimal value) { + value = value.setScale(2, RoundingMode.HALF_UP); + if (value.compareTo(BigDecimal.ZERO) == -1 || value.compareTo(BigDecimal.ONE) == 1) { + throw new IllegalArgumentException("Percentage value must be between 0 and 1; your value was " + value); + } + this.value = value; + } + + /** + * Convert the string representation of a percentage (e.g. 5% or 5) to a Percentage object. + * @param string the percentage string + * @return the percentage object + */ + public static Percentage valueOf(String string) { + if (string == null || string.length() == 0) { + throw new IllegalArgumentException("The percentage value is required"); + } + boolean percent = string.endsWith("%"); + if (percent) { + int index = string.lastIndexOf('%'); + string = string.substring(0, index); + } + BigDecimal value = new BigDecimal(string); + if (percent) { + value = value.divide(new BigDecimal(100)); + } + return new Percentage(value); + } + + /** + * Returns zero percent. + */ + public static Percentage zero() { + return new Percentage(0); + } + + /** + * Returns one hundred percent. + */ + public static Percentage oneHundred() { + return new Percentage(1); + } + + /** + * Add to this percentage. + * @param percentage the percentage to add + * @return the sum + * @throws IllegalArgumentException if the new percentage exceeds 1 + */ + public Percentage add(Percentage percentage) throws IllegalArgumentException { + return new Percentage(value.add(percentage.value)); + } + + /** + * Return this percentage as a double. Useful for when a double type is needed by an external API or system. + * @return this percentage as a double + */ + public double asDouble() { + return value.doubleValue(); + } + + /** + * Return this percentage as a big decimal. Useful for when a big decimal type is needed by an external API or + * system. + * @return this percentage as a big decimal + */ + @JsonValue + public BigDecimal asBigDecimal() { + return value; + } + + public boolean equals(Object o) { + if (!(o instanceof Percentage)) { + return false; + } + return value.equals(((Percentage) o).value); + } + + public int hashCode() { + return value.hashCode(); + } + + public String toString() { + return value.multiply(new BigDecimal("100")).setScale(0) + "%"; + } +} \ No newline at end of file diff --git a/lab/00-rewards-common/src/main/java/common/money/PercentageEditor.java b/lab/00-rewards-common/src/main/java/common/money/PercentageEditor.java new file mode 100644 index 0000000..6fb1951 --- /dev/null +++ b/lab/00-rewards-common/src/main/java/common/money/PercentageEditor.java @@ -0,0 +1,32 @@ +package common.money; + +import java.beans.PropertyEditorSupport; + +import org.springframework.util.StringUtils; + +/** + * A formatter for Percentage properties. Converts object values to well-formatted strings and strings back to values. + * Usable by a data binding framework for binding user input to the model. + */ +public class PercentageEditor extends PropertyEditorSupport { + + @Override + public String getAsText() { + Percentage percentage = (Percentage) getValue(); + if (percentage == null) { + return ""; + } else { + return percentage.toString(); + } + } + + @Override + public void setAsText(String text) throws IllegalArgumentException { + if (StringUtils.hasText(text)) { + setValue(Percentage.valueOf(text)); + } else { + setValue(null); + } + } + +} diff --git a/lab/00-rewards-common/src/main/java/common/money/package.html b/lab/00-rewards-common/src/main/java/common/money/package.html new file mode 100644 index 0000000..0f26989 --- /dev/null +++ b/lab/00-rewards-common/src/main/java/common/money/package.html @@ -0,0 +1,7 @@ + + +

+Shared classes for representing money. +

+ + diff --git a/lab/00-rewards-common/src/main/java/common/repository/Entity.java b/lab/00-rewards-common/src/main/java/common/repository/Entity.java new file mode 100644 index 0000000..c17b2ac --- /dev/null +++ b/lab/00-rewards-common/src/main/java/common/repository/Entity.java @@ -0,0 +1,34 @@ +package common.repository; + +/** + * A base class for all entities that use an internal long identifier for + * tracking entity identity. + */ +public class Entity { + + private Long entityId; + + /** + * Returns the entity identifier used to internally distinguish this entity + * among other entities of the same type in the system. Should typically + * only be called by privileged data access infrastructure code such as an + * Object Relational Mapper (ORM) and not by application code. + * + * @return the internal entity identifier + */ + public Long getEntityId() { + return entityId; + } + + /** + * Sets the internal entity identifier - should only be called by privileged + * data access code: repositories that work with an Object Relational Mapper + * (ORM). Should never be set by application code explicitly. + * + * @param entityId + * the internal entity identifier + */ + public void setEntityId(Long entityId) { + this.entityId = entityId; + } +} diff --git a/lab/00-rewards-common/src/main/java/common/repository/package.html b/lab/00-rewards-common/src/main/java/common/repository/package.html new file mode 100644 index 0000000..8134693 --- /dev/null +++ b/lab/00-rewards-common/src/main/java/common/repository/package.html @@ -0,0 +1,7 @@ + + +

+Shared classes for persisting business entities such as Account. +

+ + diff --git a/lab/00-rewards-common/src/main/resources/banner.txt b/lab/00-rewards-common/src/main/resources/banner.txt new file mode 100644 index 0000000..e5c92ab --- /dev/null +++ b/lab/00-rewards-common/src/main/resources/banner.txt @@ -0,0 +1,8 @@ +${Ansi.GREEN} :: ____ + /\ ____ ____ _ \ \ \ +|/\| / ___|___ _ __ ___ / ___| _ __ _ __(_)_ __ __ _ \ \ \ + | | / _ \| '__/ _ \ \___ \| '_ \| '__| | '_ \ / _` | \ \ \ + | |__| (_) | | | __/ ___) | |_) | | | | | | | (_| | / / / + \____\___/|_| \___| |____/| .__/|_| |_|_| |_|\__, | / / / + ================================|_|==================|___/=/_/_/= + :: Core Spring Training :: :: v3.3.1 ::${Ansi.DEFAULT} diff --git a/lab/00-rewards-common/src/main/resources/logback.xml b/lab/00-rewards-common/src/main/resources/logback.xml new file mode 100644 index 0000000..cc6eac1 --- /dev/null +++ b/lab/00-rewards-common/src/main/resources/logback.xml @@ -0,0 +1,59 @@ + + + + + + + + %-5p: %logger{40} - %msg%n + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lab/00-rewards-common/src/main/resources/rewards/testdb/data.sql b/lab/00-rewards-common/src/main/resources/rewards/testdb/data.sql new file mode 100644 index 0000000..28a87cc --- /dev/null +++ b/lab/00-rewards-common/src/main/resources/rewards/testdb/data.sql @@ -0,0 +1,78 @@ + +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (17, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) + values ('1234567890', 'AppleBees', .08, 'A'); diff --git a/lab/00-rewards-common/src/main/resources/rewards/testdb/mysql_data.sql b/lab/00-rewards-common/src/main/resources/rewards/testdb/mysql_data.sql new file mode 100644 index 0000000..a96f90a --- /dev/null +++ b/lab/00-rewards-common/src/main/resources/rewards/testdb/mysql_data.sql @@ -0,0 +1,77 @@ +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (21, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (1, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (1, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (4, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (4, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (4, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (4, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (9, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (9, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (9, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (13, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (13, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (16, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (16, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (16, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (18, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) + values ('1234567890', 'AppleBees', .08, 'A'); diff --git a/lab/00-rewards-common/src/main/resources/rewards/testdb/mysql_schema.sql b/lab/00-rewards-common/src/main/resources/rewards/testdb/mysql_schema.sql new file mode 100644 index 0000000..54e0c1f --- /dev/null +++ b/lab/00-rewards-common/src/main/resources/rewards/testdb/mysql_schema.sql @@ -0,0 +1,16 @@ +drop table if exists T_ACCOUNT_BENEFICIARY; +drop table if exists T_ACCOUNT_CREDIT_CARD; +drop table if exists T_ACCOUNT; +drop table if exists T_RESTAURANT; +drop table if exists T_REWARD; +drop table if exists DUAL_REWARD_CONFIRMATION_NUMBER; + +create table T_ACCOUNT (ID INT NOT NULL AUTO_INCREMENT, PRIMARY KEY(ID), NUMBER varchar(9), NAME varchar(50)); +create table T_ACCOUNT_CREDIT_CARD (ID INT NOT NULL AUTO_INCREMENT, PRIMARY KEY(ID), ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID INT NOT NULL AUTO_INCREMENT, PRIMARY KEY(ID), ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2), SAVINGS decimal(8,2)); +create table T_RESTAURANT (ID INT NOT NULL AUTO_INCREMENT, PRIMARY KEY(ID), MERCHANT_NUMBER varchar(10), NAME varchar(80), BENEFIT_PERCENTAGE decimal(3,2), BENEFIT_AVAILABILITY_POLICY varchar(1)); +create table T_REWARD (ID INT NOT NULL AUTO_INCREMENT, PRIMARY KEY(ID), CONFIRMATION_NUMBER varchar(25), REWARD_AMOUNT decimal(8,2), REWARD_DATE date, ACCOUNT_NUMBER varchar(9), DINING_AMOUNT decimal(8,2), DINING_MERCHANT_NUMBER varchar(10), DINING_DATE date); + +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO int); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + diff --git a/lab/00-rewards-common/src/main/resources/rewards/testdb/schema.sql b/lab/00-rewards-common/src/main/resources/rewards/testdb/schema.sql new file mode 100644 index 0000000..b0324fa --- /dev/null +++ b/lab/00-rewards-common/src/main/resources/rewards/testdb/schema.sql @@ -0,0 +1,20 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, unique(NUMBER)); +create table T_ACCOUNT_CREDIT_CARD (ID integer identity primary key, ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(3,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_CREDIT_CARD add constraint FK_ACCOUNT_CREDIT_CARD foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; \ No newline at end of file diff --git a/lab/00-rewards-common/src/test/java/common/datetime/SimpleDateEditorTests.java b/lab/00-rewards-common/src/test/java/common/datetime/SimpleDateEditorTests.java new file mode 100644 index 0000000..28ea4f7 --- /dev/null +++ b/lab/00-rewards-common/src/test/java/common/datetime/SimpleDateEditorTests.java @@ -0,0 +1,32 @@ +package common.datetime; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class SimpleDateEditorTests { + + private final SimpleDateEditor editor = new SimpleDateEditor(); + + @Test + public void testGetAsText() { + SimpleDate date = new SimpleDate(12, 29, 1977); + editor.setValue(date); + assertEquals("December 29, 1977", editor.getAsText()); + } + + @Test + public void testSetAsText() { + editor.setAsText("December 29, 1977"); + SimpleDate date = (SimpleDate) editor.getValue(); + assertEquals(new SimpleDate(12, 29, 1977), date); + } + + @Test + public void testSetAsTextBogus() { + assertThrows(IllegalArgumentException.class, () -> { + editor.setAsText("December 29th, 1977"); + }); + } +} diff --git a/lab/00-rewards-common/src/test/java/common/datetime/SimpleDateTests.java b/lab/00-rewards-common/src/test/java/common/datetime/SimpleDateTests.java new file mode 100644 index 0000000..c006ba1 --- /dev/null +++ b/lab/00-rewards-common/src/test/java/common/datetime/SimpleDateTests.java @@ -0,0 +1,42 @@ +package common.datetime; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; + +/** + * Unit tests for the "Simple Date" wrapper around a Calendar that tracks + * month/date/year only, with no provision for tracking time. + */ +public class SimpleDateTests { + + @Test + public void testToday() { + SimpleDate today = SimpleDate.today(); + Calendar cal = new GregorianCalendar(); + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + assertEquals(today.asDate(), cal.getTime()); + } + + @Test + public void testValueOfDate() { + SimpleDate today = SimpleDate.today(); + Date date = today.asDate(); + SimpleDate today2 = SimpleDate.valueOf(date); + assertEquals(today, today2); + } + + @Test + public void testValueOfTime() { + SimpleDate today = SimpleDate.today(); + long time = today.inMilliseconds(); + SimpleDate today2 = SimpleDate.valueOf(time); + assertEquals(today, today2); + } +} diff --git a/lab/00-rewards-common/src/test/java/common/money/MonetaryAmountTests.java b/lab/00-rewards-common/src/test/java/common/money/MonetaryAmountTests.java new file mode 100644 index 0000000..a6a8322 --- /dev/null +++ b/lab/00-rewards-common/src/test/java/common/money/MonetaryAmountTests.java @@ -0,0 +1,61 @@ +package common.money; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.math.BigDecimal; + +/** + * Unit tests that make sure the MonetaryAmount class works in isolation. + */ +public class MonetaryAmountTests { + @Test + public void testMonetaryAmountValueOfString() { + MonetaryAmount amount = MonetaryAmount.valueOf("$100"); + assertEquals("$100.00", amount.toString()); + } + + @Test + public void testMonetaryCreation() { + MonetaryAmount amt = MonetaryAmount.valueOf("100.00"); + assertEquals("$100.00", amt.toString()); + } + + @Test + public void testMonetaryAdd() { + MonetaryAmount amt1 = MonetaryAmount.valueOf("100.00"); + MonetaryAmount amt2 = MonetaryAmount.valueOf("100.00"); + assertEquals(MonetaryAmount.valueOf("200.00"), amt1.add(amt2)); + assertEquals("$200.00", amt1.add(amt2).toString()); + } + + @Test + public void testMultiplyByPercentage() { + MonetaryAmount amt = MonetaryAmount.valueOf("100.005"); + assertEquals(MonetaryAmount.valueOf("8.00"), amt.multiplyBy(Percentage.valueOf("8%"))); + } + + @Test + public void testMultiplyByDecimal() { + MonetaryAmount amt = MonetaryAmount.valueOf("100.005"); + assertEquals(MonetaryAmount.valueOf("8.00"), amt.multiplyBy(new BigDecimal("0.08"))); + } + + @Test + public void testDivideByMonetaryAmount() { + MonetaryAmount amt = MonetaryAmount.valueOf("100.005"); + assertEquals(new BigDecimal("12.5"), amt.divide(MonetaryAmount.valueOf("8.00"))); + } + + @Test + public void testDivideByDecimal() { + MonetaryAmount amt = MonetaryAmount.valueOf("100.005"); + assertEquals(MonetaryAmount.valueOf("8.00"), amt.divideBy(new BigDecimal("12.5"))); + } + + @Test + public void testDoubleEquality() { + MonetaryAmount amt = MonetaryAmount.valueOf(".1"); + assertEquals(new BigDecimal(".10"), amt.asBigDecimal()); + } +} diff --git a/lab/00-rewards-common/src/test/java/common/money/PercentageTests.java b/lab/00-rewards-common/src/test/java/common/money/PercentageTests.java new file mode 100644 index 0000000..6564896 --- /dev/null +++ b/lab/00-rewards-common/src/test/java/common/money/PercentageTests.java @@ -0,0 +1,41 @@ +package common.money; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Unit tests that make sure the Percentage class works in isolation. + */ +public class PercentageTests { + + + @Test + public void testPercentageValueOfString() { + Percentage percentage = Percentage.valueOf("100%"); + assertEquals("100%", percentage.toString()); + } + + @Test + public void testPercentage() { + assertEquals(Percentage.valueOf("0.01"), Percentage.valueOf("1%")); + } + + @Test + public void testPercentageEquality() { + Percentage percentage1 = Percentage.valueOf("25%"); + Percentage percentage2 = Percentage.valueOf("25%"); + assertEquals(percentage1, percentage2); + } + + @Test + public void testNewPercentage() { + Percentage p = new Percentage(.25); + assertEquals("25%", p.toString()); + } + + @Test + public void testNewPercentageWithRounding() { + Percentage p = new Percentage(.255555); + assertEquals("26%", p.toString()); + } +} diff --git a/lab/01-rewards-db/build.gradle b/lab/01-rewards-db/build.gradle new file mode 100644 index 0000000..b909d3e --- /dev/null +++ b/lab/01-rewards-db/build.gradle @@ -0,0 +1,7 @@ +apply plugin: 'java-library' + +dependencies { + api project(':00-rewards-common') + api "org.springframework:spring-orm" + api "org.hibernate.orm:hibernate-core" +} diff --git a/lab/01-rewards-db/pom.xml b/lab/01-rewards-db/pom.xml new file mode 100644 index 0000000..726ba8c --- /dev/null +++ b/lab/01-rewards-db/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + 01-rewards-db + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + org.springframework + spring-orm + + + org.hibernate.orm + hibernate-core + + + diff --git a/lab/01-rewards-db/src/main/java/accounts/AccountManager.java b/lab/01-rewards-db/src/main/java/accounts/AccountManager.java new file mode 100644 index 0000000..b3cdcf8 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/accounts/AccountManager.java @@ -0,0 +1,94 @@ +package accounts; + +import java.util.List; +import java.util.Map; + +import rewards.internal.account.Account; + +import common.money.Percentage; + +/** + * Manages access to account information. Used as the service layer component in + * the mvc and security projects. + */ +public interface AccountManager { + + /** + * Indicates implementation being used. Actual implementation is hidden + * behind a proxy, so this makes it easy to determine. + * + * @return Implementation information. + */ + String getInfo(); + + /** + * Get all accounts in the system + * + * @return all accounts + */ + List getAllAccounts(); + + /** + * Find an account by its number. + * + * @param id + * the account id + * @return the account + */ + Account getAccount(Long id); + + /** + * Takes a transient account and persists it. + * + * @param account + * The account to save + * @return The persistent account - this may or may not be the same object + * as the method argument. + */ + Account save(Account account); + + /** + * Takes a changed account and persists any changes made to it. + * + * @param account + * The account with changes + */ + void update(Account account); + + /** + * Updates the allocation percentages for the entire collection of + * beneficiaries in an account + * + * @param accountId + * the account id + * @param allocationPercentages + * A map of beneficiary names and allocation percentages, keyed + * by beneficiary name + */ + void updateBeneficiaryAllocationPercentages(Long accountId, + Map allocationPercentages); + + /** + * Adds a beneficiary to an account. The new beneficiary will have a 0 + * allocation percentage. + * + * @param accountId + * the account id + * @param beneficiaryName + * the name of the beneficiary to remove + */ + void addBeneficiary(Long accountId, String beneficiaryName); + + /** + * Removes a beneficiary from an account. + * + * @param accountId + * the account id + * @param beneficiaryName + * the name of the beneficiary to remove + * @param allocationPercentages + * new allocation percentages, keyed by beneficiary name + */ + void removeBeneficiary(Long accountId, String beneficiaryName, + Map allocationPercentages); +} diff --git a/lab/01-rewards-db/src/main/java/accounts/internal/AbstractAccountManager.java b/lab/01-rewards-db/src/main/java/accounts/internal/AbstractAccountManager.java new file mode 100644 index 0000000..abc1496 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/accounts/internal/AbstractAccountManager.java @@ -0,0 +1,23 @@ +package accounts.internal; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import accounts.AccountManager; + +public abstract class AbstractAccountManager implements AccountManager { + + protected final Logger logger; + + public AbstractAccountManager() { + logger = LoggerFactory.getLogger(getClass()); + logger.info("Created " + getInfo() + " account-manager"); + } + + @Override + public String getInfo() { + String myClassName = getClass().getSimpleName(); + int ix = myClassName.indexOf("AccountManager"); + return ix == -1 ? "UNKNOWN" : myClassName.substring(0, ix).toUpperCase(); + } +} diff --git a/lab/01-rewards-db/src/main/java/accounts/internal/JpaAccountManager.java b/lab/01-rewards-db/src/main/java/accounts/internal/JpaAccountManager.java new file mode 100644 index 0000000..4c34ace --- /dev/null +++ b/lab/01-rewards-db/src/main/java/accounts/internal/JpaAccountManager.java @@ -0,0 +1,109 @@ +package accounts.internal; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import common.money.Percentage; +import rewards.internal.account.Account; + +/** + * An account manager that uses JPA to find accounts. + */ +@Repository +public class JpaAccountManager extends AbstractAccountManager { + + private EntityManager entityManager; + + /** + * Creates a new JPA account manager. + *

+ * Its entityManager will be set automatically by + * {@link #setEntityManager(EntityManager)}. + */ + public JpaAccountManager() { + } + + @PersistenceContext + public void setEntityManager(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @Transactional(readOnly = true) + @SuppressWarnings("unchecked") + public List getAllAccounts() { + List l = entityManager.createQuery("select a from Account a LEFT JOIN FETCH a.beneficiaries") + .getResultList(); + + // Use of "JOIN FETCH" produces duplicate accounts, and DISTINCT does + // not address this. So we have to filter it manually. + List result = new ArrayList<>(); + + for (Account a : l) { + if (!result.contains(a)) + result.add(a); + } + + return result; + } + + @Override + @Transactional(readOnly = true) + public Account getAccount(Long id) { + Account account = entityManager.find(Account.class, id); + + if (account != null) { + // Force beneficiaries to load too - avoid Hibernate lazy loading error + account.getBeneficiaries().size(); + } + + return account; + } + + @Override + @Transactional + public Account save(Account account) { + entityManager.persist(account); + return account; + } + + @Override + @Transactional + public void update(Account account) { + entityManager.merge(account); + } + + @Override + @Transactional + public void updateBeneficiaryAllocationPercentages(Long accountId, Map allocationPercentages) { + Account account = getAccount(accountId); + for (Entry entry : allocationPercentages.entrySet()) { + account.getBeneficiary(entry.getKey()).setAllocationPercentage(entry.getValue()); + } + } + + @Override + @Transactional + public void addBeneficiary(Long accountId, String beneficiaryName) { + getAccount(accountId).addBeneficiary(beneficiaryName, Percentage.zero()); + } + + @Override + @Transactional + public void removeBeneficiary(Long accountId, String beneficiaryName, + Map allocationPercentages) { + getAccount(accountId).removeBeneficiary(beneficiaryName); + + if (allocationPercentages != null) + updateBeneficiaryAllocationPercentages(accountId, allocationPercentages); + } + +} diff --git a/lab/01-rewards-db/src/main/java/accounts/internal/StubAccountManager.java b/lab/01-rewards-db/src/main/java/accounts/internal/StubAccountManager.java new file mode 100644 index 0000000..d5be612 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/accounts/internal/StubAccountManager.java @@ -0,0 +1,105 @@ +package accounts.internal; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.atomic.AtomicLong; + +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import common.money.Percentage; + +/** + * IMPORTANT: Per best practices, this class shouldn't be in 'src/main/java' + * but rather in 'src/test/java'. However, it is used by numerous Test classes + * inside multiple projects. Maven does not provide an easy way to access a + * class that is inside another project's 'src/test/java' folder. + *

+ * Rather than using some complex Maven configuration, we decided it is + * acceptable to place this test class inside 'src/main/java'. + */ +public class StubAccountManager extends AbstractAccountManager { + + public static final int NUM_ACCOUNTS_IN_STUB = 1; + + public static final long TEST_ACCOUNT_ID = 0L; + public static final String TEST_ACCOUNT_NUMBER = "123456789"; + public static final String TEST_ACCOUNT_NAME = "Keith and Keri Donald"; + + public static final long TEST_BEN0_ID = 0L; + public static final String TEST_BEN0_NAME = "Annabelle"; + public static final long TEST_BEN1_ID = 1L; + public static final String TEST_BEN1_NAME = "Corgan"; + public static final String BENEFICIARY_SHARE = "50%"; + + private final Map accountsById = new HashMap<>(); + + private final AtomicLong nextEntityId = new AtomicLong(3); + + public StubAccountManager() { + // One test account + Account account = new Account(TEST_ACCOUNT_NUMBER, TEST_ACCOUNT_NAME); + account.setEntityId(TEST_ACCOUNT_ID); + + // Two test beneficiaries + account.addBeneficiary(TEST_BEN0_NAME, Percentage.valueOf(BENEFICIARY_SHARE)); + account.addBeneficiary(TEST_BEN1_NAME, Percentage.valueOf(BENEFICIARY_SHARE)); + + // Retrieve each Beneficiary and set its entityId + account.getBeneficiary(TEST_BEN0_NAME).setEntityId(TEST_BEN0_ID); + account.getBeneficiary(TEST_BEN1_NAME).setEntityId(TEST_BEN1_ID); + + // Save the account + accountsById.put(0L, account); + } + + @Override + public List getAllAccounts() { + return new ArrayList<>(accountsById.values()); + } + + @Override + public Account getAccount(Long id) { + return accountsById.get(id); + } + + @Override + public Account save(Account newAccount) { + for (Beneficiary beneficiary : newAccount.getBeneficiaries()) { + beneficiary.setEntityId(nextEntityId.getAndIncrement()); + } + + newAccount.setEntityId(nextEntityId.getAndIncrement()); + accountsById.put(newAccount.getEntityId(), newAccount); + return newAccount; + } + + @Override + public void update(Account account) { + accountsById.put(account.getEntityId(), account); + } + + @Override + public void updateBeneficiaryAllocationPercentages(Long accountId, Map allocationPercentages) { + Account account = accountsById.get(accountId); + for (Entry entry : allocationPercentages.entrySet()) { + account.getBeneficiary(entry.getKey()).setAllocationPercentage(entry.getValue()); + } + } + + @Override + public void addBeneficiary(Long accountId, String beneficiaryName) { + accountsById.get(accountId).addBeneficiary(beneficiaryName, Percentage.zero()); + } + + @Override + public void removeBeneficiary(Long accountId, String beneficiaryName, + Map allocationPercentages) { + accountsById.get(accountId).removeBeneficiary(beneficiaryName); + updateBeneficiaryAllocationPercentages(accountId, allocationPercentages); + } + +} diff --git a/lab/01-rewards-db/src/main/java/accounts/internal/package.html b/lab/01-rewards-db/src/main/java/accounts/internal/package.html new file mode 100644 index 0000000..d928cd8 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/accounts/internal/package.html @@ -0,0 +1,8 @@ + + +

The Account data management module for the MVC and later labs. + These labs only manage account data and use an AccountManager service + instead of a RewardNetwork and AccountRepository. The same underlying + database tables are used - but rewards and restaurants are ignored.

+ + diff --git a/lab/01-rewards-db/src/main/java/accounts/package.html b/lab/01-rewards-db/src/main/java/accounts/package.html new file mode 100644 index 0000000..b72f2a8 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/accounts/package.html @@ -0,0 +1,7 @@ + + +

The Account module for the MVC and later labs. These labs only + manage account data and use different definitions for Account and + Beneficiaries to the other labs.

+ + diff --git a/lab/01-rewards-db/src/main/java/config/AppConfig.java b/lab/01-rewards-db/src/main/java/config/AppConfig.java new file mode 100644 index 0000000..7194cb0 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/config/AppConfig.java @@ -0,0 +1,47 @@ +package config; + +import accounts.AccountManager; +import accounts.internal.JpaAccountManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import rewards.internal.account.AccountRepository; +import rewards.internal.account.JpaAccountRepository; +import rewards.internal.restaurant.JpaRestaurantRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.JdbcRewardRepository; +import rewards.internal.reward.RewardRepository; + +import javax.sql.DataSource; + +/** + * Rewards application configuration - services and repositories. + *

+ * Because this is used by many similar lab projects with slightly different + * classes and packages, everything is explicitly created using @Bean methods. + * Component-scanning risks picking up unwanted beans in the same package in + * other projects. + */ +@Configuration +public class AppConfig { + + @Bean + public AccountManager accountManager() { + return new JpaAccountManager(); + } + + @Bean + public AccountRepository accountRepository() { + return new JpaAccountRepository(); + } + + @Bean + public RestaurantRepository restaurantRepository() { + return new JpaRestaurantRepository(); + } + + @Bean + public RewardRepository rewardRepository(DataSource dataSource) { + return new JdbcRewardRepository(dataSource); + } + +} diff --git a/lab/01-rewards-db/src/main/java/config/DbConfig.java b/lab/01-rewards-db/src/main/java/config/DbConfig.java new file mode 100644 index 0000000..f006a58 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/config/DbConfig.java @@ -0,0 +1,106 @@ +package config; + +import java.util.Properties; +import java.util.logging.Logger; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.context.annotation.PropertySource; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.JpaVendorAdapter; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.vendor.AbstractJpaVendorAdapter; +import org.springframework.orm.jpa.vendor.Database; +import org.springframework.orm.jpa.vendor.EclipseLinkJpaVendorAdapter; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Configuration class for Persistence-specific objects, including profile + * choices for JPA via Hibernate or JPA via EclipseLink. Only used by tests in + * this class (since Spring Boot cannot be assumed). + *

+ * To simulate Spring Boot we load application.properties manually, + * if it exists, and mimic Boot's spring.jpa.show-sql property. + */ +@Configuration +@PropertySource(value = "application.properties", ignoreResourceNotFound = true) +public class DbConfig { + + public static final String DOMAIN_OBJECTS_PARENT_PACKAGE = "rewards.internal"; + + @Value("${spring.jpa.show-sql:true}") // Default to true if not set elsewhere + private String showSql; + + /** + * Creates an in-memory "rewards" database populated with test data for fast + * testing + */ + @Bean + public DataSource dataSource() { + return (new EmbeddedDatabaseBuilder()) // + .addScript("classpath:rewards/testdb/schema.sql") // + .addScript("classpath:rewards/testdb/data.sql").build(); + } + + /** + * Transaction Manager For JPA + */ + @Bean + public PlatformTransactionManager transactionManager() { + return new JpaTransactionManager(); + } + + /** + * Create an EntityManagerFactoryBean. + */ + @Bean + public LocalContainerEntityManagerFactoryBean entityManagerFactory(JpaVendorAdapter adapter) { + + // Tell the underlying implementation what type of database we are using - a + // hint to generate better SQL + if (adapter instanceof AbstractJpaVendorAdapter vendorAdapter) { + vendorAdapter.setDatabase(Database.HSQL); + } + + // Setup configuration properties + Properties props = new Properties(); + boolean showSql = "TRUE".equalsIgnoreCase(this.showSql); + Logger.getLogger("config").info("JPA Show generated SQL? " + this.showSql); + + if (adapter instanceof EclipseLinkJpaVendorAdapter) { + props.setProperty("eclipselink.logging.level", showSql ? "FINE" : "WARN"); + props.setProperty("eclipselink.logging.parameters", String.valueOf(showSql)); + props.setProperty("eclipselink.weaving", "false"); + } else { + props.setProperty("hibernate.show_sql", String.valueOf(showSql)); + props.setProperty("hibernate.format_sql", "true"); + } + + LocalContainerEntityManagerFactoryBean emfb = new LocalContainerEntityManagerFactoryBean(); + emfb.setPackagesToScan(DOMAIN_OBJECTS_PARENT_PACKAGE); + emfb.setJpaProperties(props); + emfb.setJpaVendorAdapter(adapter); + emfb.setDataSource(dataSource()); + + return emfb; + } + + @Bean + @Profile("!jpa-elink") // Default is JPA using Hibernate + JpaVendorAdapter hibernateVendorAdapter() { + return new HibernateJpaVendorAdapter(); + } + + @Bean + @Profile("jpa-elink") // Explicitly request JPA using EclipseLink + JpaVendorAdapter eclipseLinkVendorAdapter() { + return new EclipseLinkJpaVendorAdapter(); + } + +} diff --git a/lab/01-rewards-db/src/main/java/rewards/AccountContribution.java b/lab/01-rewards-db/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..94774dd --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,140 @@ +package rewards; + +import java.io.Serializable; +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +@SuppressWarnings("serial") +public class AccountContribution implements Serializable { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution implements Serializable { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/rewards/Dining.java b/lab/01-rewards-db/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..e656eef --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/Dining.java @@ -0,0 +1,116 @@ +package rewards; + +import java.io.Serializable; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +@SuppressWarnings("serial") +public class Dining implements Serializable { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/rewards/RewardConfirmation.java b/lab/01-rewards-db/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..d04184d --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,44 @@ +package rewards; + +import java.io.Serializable; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +@SuppressWarnings("serial") +public class RewardConfirmation implements Serializable { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/account/Account.java b/lab/01-rewards-db/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..e9984a4 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,290 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * An account for a member of the reward network. An account has one or more + * beneficiaries whose allocations must add up to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is + * distributed among the beneficiaries based on an allocation. + * + * An entity. An aggregate. + */ +@Entity +@Table(name = "T_ACCOUNT") +public class Account { + + @Id + @Column(name = "ID") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long entityId; + + @Column(name = "NUMBER") + private String number; + + @Column(name = "NAME") + private String name; + + @OneToMany(cascade = CascadeType.ALL) + @JoinColumn(name = "ACCOUNT_ID") + private Set beneficiaries = new HashSet<>(); + + protected Account() { + } + + /** + * Create a new account. + * + * @param number + * the account number + * @param name + * the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the entity identifier used to internally distinguish this entity + * among other entities of the same type in the system. Should typically + * only be called by privileged data access infrastructure code such as an + * Object Relational Mapper (ORM) and not by application code. + * + * @return the internal entity identifier + */ + public Long getEntityId() { + return entityId; + } + + /** + * Sets the internal entity identifier - should only be called by privileged + * data access code (repositories that work with an Object Relational Mapper + * (ORM)). Should never be set by application code explicitly. + * + * @param entityId + * the internal entity identifier + */ + public void setEntityId(Long entityId) { + this.entityId = entityId; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Sets the number used to uniquely identify this account. + * + * @param number + * The number for this account + */ + public void setNumber(String number) { + this.number = number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Sets the name on file for this account. + * + * @param name + * The name for this account + */ + public void setName(String name) { + this.name = name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * + * @param beneficiaryName + * the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * + * @param beneficiaryName + * the name of the beneficiary (should be unique) + * @param allocationPercentage + * the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, + Percentage allocationPercentage) { + beneficiaries + .add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Returns the beneficiaries for this account. + *

+ * Callers should not attempt to hold on or modify the returned set. This + * method should only be used transitively; for example, called to + * facilitate account reporting. + * + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Returns a single account beneficiary. Callers should not attempt to hold + * on or modify the returned object. This method should only be used + * transitively; for example, called to facilitate reporting or testing. + * + * @param name + * the name of the beneficiary e.g "Annabelle" + * @return the beneficiary object + */ + public Beneficiary getBeneficiary(String name) { + for (Beneficiary b : beneficiaries) { + if (b.getName().equals(name)) { + return b; + } + } + throw new IllegalArgumentException("No such beneficiary with name '" + + name + "'"); + } + + /** + * Removes a single beneficiary from this account. + * + * @param beneficiaryName + * the name of the beneficiary (should be unique) + */ + public void removeBeneficiary(String beneficiaryName) { + beneficiaries.remove(getBeneficiary(beneficiaryName)); + } + + /** + * Validation check that returns true only if the total beneficiary + * allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + try { + totalPercentage = totalPercentage.add(b + .getAllocationPercentage()); + } catch (IllegalArgumentException e) { + // total would have been over 100% - return invalid + return false; + } + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + public void setValid(boolean valid) { + // DO NOTHING. Needed for JSON processing on client. + } + + /** + * Make a monetary contribution to this account. The contribution amount is + * distributed among the account's beneficiaries based on each beneficiary's + * allocation percentage. + * + * @param amount + * the total amount to contribute + * @param contribution + * the contribution summary + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * + * @param amount + * the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>( + beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary + .getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), + distributionAmount, beneficiary.getAllocationPercentage(), + beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the + * repository responsible for reconstituting this account. + * + * @param beneficiary + * the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + /** + * String representation for debugging. + */ + public String toString() { + return entityId + ": Number = '" + number + "', name = " + name + + "', beneficiaries = " + beneficiaries; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Account account = (Account) o; + return Objects.equals(entityId, account.entityId) && + Objects.equals(number, account.number) && + Objects.equals(name, account.name) && + Objects.equals(beneficiaries, account.beneficiaries); + } + + @Override + public int hashCode() { + + return Objects.hash(entityId, number, name, beneficiaries); + } +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/account/AccountRepository.java b/lab/01-rewards-db/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..fd3d23b --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,26 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Indicates implementation being used. Actual implementation is hidden + * behind a proxy, so this makes it easy to determine when testing. + * + * @return Implementation information. + */ + String getInfo(); + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/account/Beneficiary.java b/lab/01-rewards-db/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..9b761be --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,146 @@ +package rewards.internal.account; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name + * (e.g. Mary), an allocation percentage and a savings balance tracking how much + * money has been saved for them to date (e.g. $1000). + */ +@Entity +@Table(name = "T_ACCOUNT_BENEFICIARY") +public class Beneficiary { + + @Id + @Column(name = "ID") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long entityId; + + @Column(name = "NAME") + private String name; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "ALLOCATION_PERCENTAGE")) + private Percentage allocationPercentage; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "SAVINGS")) + private MonetaryAmount savings = MonetaryAmount.zero(); + + protected Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * + * @param name + * the name of the beneficiary + * @param allocationPercentage + * the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by + * privileged objects responsible for reconstituting an existing Account + * object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be + * called by general application code. + * + * @param name + * the name of the beneficiary + * @param allocationPercentage + * the beneficiary's allocation percentage within its account + * @param savings + * the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, + MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the entity identifier used to internally distinguish this entity + * among other entities of the same type in the system. Should typically + * only be called by privileged data access infrastructure code such as an + * Object Relational Mapper (ORM) and not by application code. + * + * @return the internal entity identifier + */ + public Long getEntityId() { + return entityId; + } + + /** + * Sets the internal entity identifier - should only be called by privileged + * data access code (repositories that work with an Object Relational Mapper + * (ORM)). Should never be set by application code explicitly. + * + * @param entityId + * the internal entity identifier + */ + public void setEntityId(Long entityId) { + this.entityId = entityId; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Sets the beneficiary's allocation percentage in this account. + * + * @param allocationPercentage + * The new allocation percentage + */ + public void setAllocationPercentage(Percentage allocationPercentage) { + this.allocationPercentage = allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * + * @param amount + * the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "' (" + entityId + "), allocationPercentage = " + + allocationPercentage + ", savings = " + savings; + } +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/account/JpaAccountRepository.java b/lab/01-rewards-db/src/main/java/rewards/internal/account/JpaAccountRepository.java new file mode 100644 index 0000000..dcc4914 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/account/JpaAccountRepository.java @@ -0,0 +1,51 @@ +package rewards.internal.account; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An account repository that uses JPA to find accounts. + */ +public class JpaAccountRepository implements AccountRepository { + + public static final String ACCOUNT_BY_CC_QUERY = "select ACCOUNT_ID from T_ACCOUNT_CREDIT_CARD where NUMBER = :ccn"; + + public static final String INFO = "JPA"; + + private static final Logger logger = LoggerFactory.getLogger("config"); + + private EntityManager entityManager; + + public JpaAccountRepository() { + logger.info("Created JpaAccountManager"); + } + + @PersistenceContext + public void setEntityManager(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public String getInfo() { + return INFO; + } + + public Account findByCreditCard(String creditCardNumber) { + // Find id account of account with this credit-card using a direct + // SQL query on the unmapped T_ACCOUNT_CREDIT_CARD table. + Integer accountId = (Integer) entityManager + .createNativeQuery(ACCOUNT_BY_CC_QUERY) + .setParameter("ccn", creditCardNumber).getSingleResult(); + + Account account = entityManager.find(Account.class, accountId.longValue()); + + // Force beneficiaries to load too - avoid Hibernate lazy loading error + account.getBeneficiaries().size(); + + return account; + } + +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/account/StubAccountRepository.java b/lab/01-rewards-db/src/main/java/rewards/internal/account/StubAccountRepository.java new file mode 100644 index 0000000..7437a5e --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/account/StubAccountRepository.java @@ -0,0 +1,48 @@ +package rewards.internal.account; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.orm.ObjectRetrievalFailureException; + + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + public static final String TYPE = "Stub"; + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + @Override + public String getInfo() { + return TYPE; + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new ObjectRetrievalFailureException(Account.class, creditCardNumber); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/account/package.html b/lab/01-rewards-db/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/AlwaysAvailable.java b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/AlwaysAvailable.java new file mode 100644 index 0000000..f97fbeb --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/AlwaysAvailable.java @@ -0,0 +1,19 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * A benefit availabilty policy that returns true at all times. + */ +public class AlwaysAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new AlwaysAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + + public String toString() { + return "alwaysAvailable"; + } +} diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java new file mode 100644 index 0000000..b7d6d74 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java @@ -0,0 +1,20 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * Determines if benefit is available for an account for dining. + * + * A value object. A strategy. Scoped by the Resturant aggregate. + */ +public interface BenefitAvailabilityPolicy { + + /** + * Calculates if an account is eligible to receive benefits for a dining. + * @param account the account of the member who dined + * @param dining the dining event + * @return benefit availability status + */ + boolean isBenefitAvailableFor(Account account, Dining dining); +} diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/JpaRestaurantRepository.java b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/JpaRestaurantRepository.java new file mode 100644 index 0000000..835c58c --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/JpaRestaurantRepository.java @@ -0,0 +1,56 @@ +package rewards.internal.restaurant; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; + +/** + * Loads restaurants from a data source using JPA. + */ +public class JpaRestaurantRepository implements RestaurantRepository { + + public static final String RESTAURANT_BY_MERCHANT_QUERY = // + "select r from Restaurant r where r.number = :merchantNumber"; + + public static final String INFO = "JPA"; + + private static final Logger logger = LoggerFactory.getLogger("config"); + + private EntityManager entityManager; + + public JpaRestaurantRepository() { + logger.info("Created JpaRestaurantRepository"); + } + + @PersistenceContext + public void setEntityManager(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public String getInfo() { + return INFO; + } + + @Override + public Restaurant findByMerchantNumber(String merchantNumber) { + return entityManager // + .createQuery(RESTAURANT_BY_MERCHANT_QUERY, Restaurant.class) // + .setParameter("merchantNumber", merchantNumber) // + .getSingleResult(); + } + + @Override + public Long getRestaurantCount() { + CriteriaBuilder qb = entityManager.getCriteriaBuilder(); + + CriteriaQuery cq = qb.createQuery(Long.class); + cq.select(qb.count(cq.from(Restaurant.class))); + + return entityManager.createQuery(cq).getSingleResult(); + } +} diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/NeverAvailable.java b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/NeverAvailable.java new file mode 100644 index 0000000..8f6cbc3 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/NeverAvailable.java @@ -0,0 +1,19 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * A benefit availabilty policy that returns false at all times. + */ +public class NeverAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new NeverAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return false; + } + + public String toString() { + return "neverAvailable"; + } +} diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..20d3597 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,207 @@ +package rewards.internal.restaurant; + +import jakarta.persistence.Access; +import jakarta.persistence.AccessType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for + * dining based on an availability policy and a benefit percentage. + */ +@Entity +@Table(name = "T_RESTAURANT") +public class Restaurant { + + @Id + @Column(name = "ID") + private Long entityId; + + @Column(name = "MERCHANT_NUMBER") + private String number; + + @Column(name = "NAME") + private String name; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name="BENEFIT_PERCENTAGE")) + private Percentage benefitPercentage; + + /** + * This class needs special mapping via its own accessor methods - see: + *
    + *
  • {@link #getDbBenefitAvailabilityPolicy()}
  • + *
  • {@link #setDbBenefitAvailabilityPolicy(String)}
  • + *
+ * It is marked transient to hide it from the default field mapping + * mechanism. + */ + @Transient + //@Column(name = "BENEFIT_AVAILABILITY_POLICY") + private BenefitAvailabilityPolicy benefitAvailabilityPolicy; + + protected Restaurant() { + } + + /** + * Creates a new restaurant. + * + * @param number + * the restaurant's merchant number + * @param name + * the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the entity identifier used to internally distinguish this entity + * among other entities of the same type in the system. Should typically + * only be called by privileged data access infrastructure code such as an + * Object Relational Mapper (ORM) and not by application code. + * + * @return the internal entity identifier + */ + protected Long getEntityId() { + return entityId; + } + + /** + * Sets the internal entity identifier - should only be called by privileged + * data access code (repositories that work with an Object Relational Mapper + * (ORM)). Should never be set by application code explicitly. + * + * @param entityId + * the internal entity identifier + */ + public void setEntityId(Long entityId) { + this.entityId = entityId; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining + * transactions. + * + * @param benefitPercentage + * the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Sets the policy that determines if a dining by an account at this + * restaurant is eligible for benefit. + * + * @param benefitAvailabilityPolicy + * the benefit availability policy + */ + public void setBenefitAvailabilityPolicy( + BenefitAvailabilityPolicy benefitAvailabilityPolicy) { + this.benefitAvailabilityPolicy = benefitAvailabilityPolicy; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Returns this restaurant's benefit availability policy. + */ + public BenefitAvailabilityPolicy getBenefitAvailabilityPolicy() { + return benefitAvailabilityPolicy; + } + + /** + * Calculate the benefit eligible to this account for dining at this + * restaurant. + * + * @param account + * the account that dined at this restaurant + * @param dining + * a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + if (benefitAvailabilityPolicy.isBenefitAvailableFor(account, dining)) { + return dining.getAmount().multiplyBy(benefitPercentage); + } else { + return MonetaryAmount.zero(); + } + } + + // Internal methods for JPA only - hence they are protected. + /** + * Sets this restaurant's benefit availability policy from the code stored + * in the underlying column. This method is a database specific accessor + * using the JPA 2 @Access annotation. + */ + @Access(AccessType.PROPERTY) + @Column(name = "BENEFIT_AVAILABILITY_POLICY") + protected void setDbBenefitAvailabilityPolicy(String policyCode) { + if ("A".equals(policyCode)) { + benefitAvailabilityPolicy = AlwaysAvailable.INSTANCE; + } else if ("N".equals(policyCode)) { + benefitAvailabilityPolicy = NeverAvailable.INSTANCE; + } else { + throw new IllegalArgumentException("Not a supported policy code " + + policyCode); + } + } + + /** + * Returns this restaurant's benefit availability policy code for storage in + * the underlying column. This method is a database specific accessor using + * the JPA 2 @Access annotation. + */ + @Access(AccessType.PROPERTY) + @Column(name = "BENEFIT_AVAILABILITY_POLICY") + protected String getDbBenefitAvailabilityPolicy() { + if (benefitAvailabilityPolicy == AlwaysAvailable.INSTANCE) { + return "A"; + } else if (benefitAvailabilityPolicy == NeverAvailable.INSTANCE) { + return "N"; + } else { + throw new IllegalArgumentException("No policy code for " + + benefitAvailabilityPolicy.getClass()); + } + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + + "', benefitPercentage = " + benefitPercentage + + ", benefitAvailabilityPolicy = " + benefitAvailabilityPolicy; + } +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..2dbdff0 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,36 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and + * reconstitute Restaurant entities from an external form such as a set of RDMS + * rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized + * and ready to use. + */ +public interface RestaurantRepository { + + /** + * Indicates implementation being used. Actual implementation is hidden + * behind a proxy, so this makes it easy to determine when testing. + * + * @return Implementation information. + */ + String getInfo(); + + /** + * Load a Restaurant entity by its merchant number. + * + * @param merchantNumber + * the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); + + /** + * Find the number of restaurants in the repository. + * + * @return The number of restaurants - zero or more. + */ + Long getRestaurantCount(); +} diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/StubRestaurantRepository.java b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/StubRestaurantRepository.java new file mode 100644 index 0000000..14c236c --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/StubRestaurantRepository.java @@ -0,0 +1,63 @@ +package rewards.internal.restaurant; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.orm.ObjectRetrievalFailureException; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + public static final String TYPE = "Stub"; + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new AlwaysReturnsTrue()); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + @Override + public String getInfo() { + return TYPE; + } + + @Override + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new ObjectRetrievalFailureException(Restaurant.class, merchantNumber); + } + return restaurant; + } + + @Override + public Long getRestaurantCount() { + return 1L; + } + + /** + * A simple "dummy" benefit availability policy that always returns true. Only useful for testing--a real + * availability policy might consider many factors such as the day of week of the dining, or the account's reward + * history for the current month. + */ + private static class AlwaysReturnsTrue implements BenefitAvailabilityPolicy { + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + } +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/package.html b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/01-rewards-db/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..8ac9658 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,50 @@ +package rewards.internal.reward; + +import javax.sql.DataSource; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; + +import common.datetime.SimpleDate; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * JDBC implementation of a reward repository that records the result of a + * reward transaction by inserting a reward confirmation record. + */ +public class JdbcRewardRepository implements RewardRepository { + + public static final String TYPE = "jdbc"; + + private static final Logger logger = LoggerFactory.getLogger("config"); + + private final JdbcTemplate jdbcTemplate; + + public JdbcRewardRepository(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + logger.info("Created JdbcRewardRepository"); + } + + @Override + public String getInfo() { + return TYPE; + } + + @Override + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + String confirmationNumber = nextConfirmationNumber(); + jdbcTemplate.update(sql, confirmationNumber, contribution.getAmount().asBigDecimal(), + SimpleDate.today().asDate(), contribution.getAccountNumber(), dining.getMerchantNumber(), + dining.getDate().asDate(), dining.getAmount().asBigDecimal()); + return new RewardConfirmation(confirmationNumber, contribution); + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + return jdbcTemplate.queryForObject(sql, String.class); + } +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/01-rewards-db/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1dd257c --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,28 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Indicates implementation being used. Actual implementation is hidden + * behind a proxy, so this makes it easy to determine when testing. + * + * @return Implementation information. + */ + String getInfo(); + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/reward/StubRewardRepository.java b/lab/01-rewards-db/src/main/java/rewards/internal/reward/StubRewardRepository.java new file mode 100644 index 0000000..2f89f16 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/reward/StubRewardRepository.java @@ -0,0 +1,37 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * A dummy reward repository implementation. + * + * IMPORTANT!!! Per best practices, this class shouldn't be in 'src/main/java' + * but rather in 'src/test/java'. However, it is used by numerous Test classes + * inside multiple projects. Maven does not provide an easy way to access a + * class that is inside another project's 'src/test/java' folder. + * + * Rather than using some complex Maven configuration, we decided it is + * acceptable to place this test class inside 'src/main/java'. + */ +public class StubRewardRepository implements RewardRepository { + + public static final String TYPE = "Stub"; + + int nextConfirmationNumber = 0; + + @Override + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + @Override + public String getInfo() { + return TYPE; + } + + private String confirmationNumber() { + return String.valueOf(nextConfirmationNumber++); + } +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/rewards/internal/reward/package.html b/lab/01-rewards-db/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/01-rewards-db/src/main/java/rewards/package.html b/lab/01-rewards-db/src/main/java/rewards/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/01-rewards-db/src/main/java/static/images/spring-logo.svg b/lab/01-rewards-db/src/main/java/static/images/spring-logo.svg new file mode 100644 index 0000000..8a63385 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/static/images/spring-logo.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + diff --git a/lab/01-rewards-db/src/main/java/static/images/spring-trans-dark.png b/lab/01-rewards-db/src/main/java/static/images/spring-trans-dark.png new file mode 100644 index 0000000..bdb7792 Binary files /dev/null and b/lab/01-rewards-db/src/main/java/static/images/spring-trans-dark.png differ diff --git a/lab/01-rewards-db/src/main/java/static/resources/images/spring-logo.svg b/lab/01-rewards-db/src/main/java/static/resources/images/spring-logo.svg new file mode 100644 index 0000000..8a63385 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/static/resources/images/spring-logo.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + diff --git a/lab/01-rewards-db/src/main/java/static/resources/images/spring-trans-dark.png b/lab/01-rewards-db/src/main/java/static/resources/images/spring-trans-dark.png new file mode 100644 index 0000000..bdb7792 Binary files /dev/null and b/lab/01-rewards-db/src/main/java/static/resources/images/spring-trans-dark.png differ diff --git a/lab/01-rewards-db/src/main/java/static/resources/readme.txt b/lab/01-rewards-db/src/main/java/static/resources/readme.txt new file mode 100644 index 0000000..61b80ad --- /dev/null +++ b/lab/01-rewards-db/src/main/java/static/resources/readme.txt @@ -0,0 +1,25 @@ +The only purpose of this /static/resources folder and files is to make +is slightly easier to serve up resources for boot and non-boot resources +in the same course. + +In the mvc labs, the pages will refer to the resources under "/resources/styles" +and "/resources/images", and a special resource handler is needed to translate +"/resources/**" into "classpath:/static/*". This works beautifully. + +But boot handles static classpath resources even more beautifully. +Boot finds resources automatically from /static, /resources, etc. etc. with no special +mapping needed. When we copy the pages that work in the non-boot labs to the boot lab, +references to "/resources/styles" would need to be located under "/static/resources/styles" + +We could easily resolve this by adding a resource handler to the boot app like this: + +@Configuration +public class MvcConfig extends WebMvcConfigurerAdapter { + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/resources/**") + .addResourceLocations("classpath:/static/"); + } +} + but this would be a bit challenging to explain, and would give the impression that +boot requires more special configuration than a normal Spring app, which is certainly +not the impression we wish to leave students with. \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/css/bootstrap-theme.min.css b/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/css/bootstrap-theme.min.css new file mode 100644 index 0000000..61358b1 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/css/bootstrap-theme.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */.btn-danger,.btn-default,.btn-info,.btn-primary,.btn-success,.btn-warning{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning:active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-danger.disabled,.btn-danger[disabled],.btn-default.disabled,.btn-default[disabled],.btn-info.disabled,.btn-info[disabled],.btn-primary.disabled,.btn-primary[disabled],.btn-success.disabled,.btn-success[disabled],.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-danger,fieldset[disabled] .btn-default,fieldset[disabled] .btn-info,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-success,fieldset[disabled] .btn-warning{-webkit-box-shadow:none;box-shadow:none}.btn-danger .badge,.btn-default .badge,.btn-info .badge,.btn-primary .badge,.btn-success .badge,.btn-warning .badge{text-shadow:none}.btn.active,.btn:active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-o-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#e0e0e0));background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc}.btn-default:focus,.btn-default:hover{background-color:#e0e0e0;background-position:0 -15px}.btn-default.active,.btn-default:active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#e0e0e0;background-image:none}.btn-primary{background-image:-webkit-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-o-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#265a88));background-image:linear-gradient(to bottom,#337ab7 0,#265a88 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#245580}.btn-primary:focus,.btn-primary:hover{background-color:#265a88;background-position:0 -15px}.btn-primary.active,.btn-primary:active{background-color:#265a88;border-color:#245580}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#265a88;background-image:none}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#419641));background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:focus,.btn-success:hover{background-color:#419641;background-position:0 -15px}.btn-success.active,.btn-success:active{background-color:#419641;border-color:#3e8f3e}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#419641;background-image:none}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#2aabd2));background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:focus,.btn-info:hover{background-color:#2aabd2;background-position:0 -15px}.btn-info.active,.btn-info:active{background-color:#2aabd2;border-color:#28a4c9}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#2aabd2;background-image:none}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#eb9316));background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:focus,.btn-warning:hover{background-color:#eb9316;background-position:0 -15px}.btn-warning.active,.btn-warning:active{background-color:#eb9316;border-color:#e38d13}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#eb9316;background-image:none}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c12e2a));background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:focus,.btn-danger:hover{background-color:#c12e2a;background-position:0 -15px}.btn-danger.active,.btn-danger:active{background-color:#c12e2a;border-color:#b92c28}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#c12e2a;background-image:none}.img-thumbnail,.thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{background-color:#2e6da4;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-o-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#f8f8f8));background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-o-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dbdbdb),to(#e2e2e2));background-image:linear-gradient(to bottom,#dbdbdb 0,#e2e2e2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-o-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#3c3c3c),to(#222));background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-o-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#080808),to(#0f0f0f));background-image:linear-gradient(to bottom,#080808 0,#0f0f0f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#c8e5bc));background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);background-repeat:repeat-x;border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#b9def0));background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);background-repeat:repeat-x;border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#f8efc0));background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);background-repeat:repeat-x;border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-o-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#e7c3c3));background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);background-repeat:repeat-x;border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#ebebeb),to(#f5f5f5));background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x}.progress-bar{background-image:-webkit-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-o-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#286090));background-image:linear-gradient(to bottom,#337ab7 0,#286090 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);background-repeat:repeat-x}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#449d44));background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);background-repeat:repeat-x}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#31b0d5));background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);background-repeat:repeat-x}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#ec971f));background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);background-repeat:repeat-x}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c9302c));background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);background-repeat:repeat-x}.progress-bar-striped{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{text-shadow:0 -1px 0 #286090;background-image:-webkit-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2b669a));background-image:linear-gradient(to bottom,#337ab7 0,#2b669a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);background-repeat:repeat-x;border-color:#2b669a}.list-group-item.active .badge,.list-group-item.active:focus .badge,.list-group-item.active:hover .badge{text-shadow:none}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#d0e9c6));background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);background-repeat:repeat-x}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#c4e3f3));background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);background-repeat:repeat-x}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#faf2cc));background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);background-repeat:repeat-x}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-o-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#ebcccc));background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);background-repeat:repeat-x}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#e8e8e8),to(#f5f5f5));background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x;border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/css/bootstrap.min.css b/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/css/bootstrap.min.css new file mode 100644 index 0000000..d65c66b --- /dev/null +++ b/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/css/bootstrap.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0;font-size:2em}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;-webkit-box-shadow:none!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\2a"}.glyphicon-plus:before{content:"\2b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:focus,a:hover{color:#23527c;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{display:inline-block;max-width:100%;height:auto;padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role=button]{cursor:pointer}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{padding:.2em;background-color:#fcf8e3}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:focus,a.text-primary:hover{color:#286090}.text-success{color:#3c763d}a.text-success:focus,a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:focus,a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:focus,a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:focus,a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:focus,a.bg-primary:hover{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ol,ul{margin-top:0;margin-bottom:10px}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;margin-left:-5px;list-style:none}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}dl{margin-top:0;margin-bottom:20px}dd,dt{line-height:1.42857143}dt{font-weight:700}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;overflow:hidden;clear:left;text-align:right;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[data-original-title],abbr[title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote .small:before,blockquote footer:before,blockquote small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;text-align:right;border-right:5px solid #eee;border-left:0}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:''}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;font-weight:700;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{margin-right:-15px;margin-left:-15px}.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*=col-]{position:static;display:table-column;float:none}table td[class*=col-],table th[class*=col-]{position:static;display:table-cell;float:none}.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{min-height:.01%;overflow-x:auto}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:700}input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=file]:focus,input[type=checkbox]:focus,input[type=radio]:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type=search]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=time].form-control,input[type=datetime-local].form-control,input[type=month].form-control{line-height:34px}.input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{line-height:30px}.input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox label,.radio label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-top:4px\9;margin-left:-20px}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;font-weight:400;vertical-align:middle;cursor:pointer}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{cursor:not-allowed}.checkbox.disabled label,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .radio label{cursor:not-allowed}.form-control-static{min-height:34px;padding-top:7px;padding-bottom:7px;margin-bottom:0}.form-control-static.input-lg,.form-control-static.input-sm{padding-right:0;padding-left:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.has-success .form-control-feedback{color:#3c763d}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.form-horizontal .control-label{padding-top:7px;margin-bottom:0;text-align:right}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:14.33px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:400;line-height:1.42857143;text-align:center;white-space:nowrap;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-image:none;border:1px solid transparent;border-radius:4px}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#333;text-decoration:none}.btn.active,.btn:active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none;opacity:.65}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default.focus,.btn-default:focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.dropdown-toggle.btn-default.focus,.open>.dropdown-toggle.btn-default:focus,.open>.dropdown-toggle.btn-default:hover{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.dropdown-toggle.btn-primary.focus,.open>.dropdown-toggle.btn-primary:focus,.open>.dropdown-toggle.btn-primary:hover{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.dropdown-toggle.btn-success.focus,.open>.dropdown-toggle.btn-success:focus,.open>.dropdown-toggle.btn-success:hover{color:#fff;background-color:#398439;border-color:#255625}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.dropdown-toggle.btn-info.focus,.open>.dropdown-toggle.btn-info:focus,.open>.dropdown-toggle.btn-info:hover{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.dropdown-toggle.btn-warning.focus,.open>.dropdown-toggle.btn-warning:focus,.open>.dropdown-toggle.btn-warning:hover{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.dropdown-toggle.btn-danger.focus,.open>.dropdown-toggle.btn-danger:focus,.open>.dropdown-toggle.btn-danger:hover{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{font-weight:400;color:#337ab7;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease;-webkit-transition-duration:.35s;-o-transition-duration:.35s;transition-duration:.35s;-webkit-transition-property:height,visibility;-o-transition-property:height,visibility;transition-property:height,visibility}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown,.dropup{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;text-align:left;list-style:none;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175)}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;background-color:#337ab7;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{right:0;left:auto}.dropdown-menu-left{right:auto;left:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{content:"";border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{right:auto;left:0}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:0;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-top-right-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-right:0;padding-left:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group .form-control,.input-group-addon,.input-group-btn{display:table-cell}.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{padding-right:15px;padding-left:15px;overflow-x:visible;-webkit-overflow-scrolling:touch;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1)}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-right:0;padding-left:0}}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;height:50px;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-top:8px;margin-right:15px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{padding:10px 15px;margin-top:8px;margin-right:-15px;margin-bottom:8px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1)}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-left-radius:0;border-top-right-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}}@media (min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#555;background-color:#e7e7e7}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#080808}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.42857143;color:#337ab7;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:4px;border-bottom-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:3;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:2;color:#fff;cursor:default;background-color:#337ab7;border-color:#337ab7}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:6px;border-bottom-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:3px;border-bottom-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:focus,.label-success[href]:hover{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:middle;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#eee}.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-right:60px;padding-left:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail a>img,.thumbnail>img{margin-right:auto;margin-left:auto}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#337ab7}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:700}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{overflow:hidden;zoom:1}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{color:#555;text-decoration:none;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#777;cursor:not-allowed;background-color:#eee}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-left-radius:3px;border-top-right-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-left-radius:3px;border-top-right-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px;padding-left:15px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;filter:alpha(opacity=20);opacity:.2}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;filter:alpha(opacity=50);opacity:.5}button.close{-webkit-appearance:none;padding:0;cursor:pointer;background:0 0;border:0}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;overflow:hidden;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transition:-webkit-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out;-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%)}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;outline:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5)}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{filter:alpha(opacity=0);opacity:0}.modal-backdrop.in{filter:alpha(opacity=50);opacity:.5}.modal-header{min-height:16.43px;padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:12px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;filter:alpha(opacity=0);opacity:0;line-break:auto}.tooltip.in{filter:alpha(opacity=90);opacity:.9}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px;bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2);line-break:auto}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{padding:8px 14px;margin:0;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{content:"";border-width:10px}.popover.top>.arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,.25);border-bottom-width:0}.popover.top>.arrow:after{bottom:1px;margin-left:-10px;content:" ";border-top-color:#fff;border-bottom-width:0}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,.25);border-left-width:0}.popover.right>.arrow:after{bottom:-10px;left:1px;content:" ";border-right-color:#fff;border-left-width:0}.popover.bottom>.arrow{top:-11px;left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25)}.popover.bottom>.arrow:after{top:1px;margin-left:-10px;content:" ";border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{right:1px;bottom:-10px;content:" ";border-right-width:0;border-left-color:#fff}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>a>img,.carousel-inner>.item>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.active.right,.carousel-inner>.item.next{left:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{left:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{left:0;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);filter:alpha(opacity=50);opacity:.5}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001)));background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);background-repeat:repeat-x}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5)));background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);background-repeat:repeat-x}.carousel-control:focus,.carousel-control:hover{color:#fff;text-decoration:none;filter:alpha(opacity=90);outline:0;opacity:.9}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;z-index:5;display:inline-block;margin-top:-10px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;font-family:serif;line-height:1}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000\9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-15px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-15px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-15px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{display:table;content:" "}.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-lg,.visible-md,.visible-sm,.visible-xs{display:none!important}.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}}@media (max-width:767px){.visible-xs-block{display:block!important}}@media (max-width:767px){.visible-xs-inline{display:inline!important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}}@media (min-width:1200px){.visible-lg-block{display:block!important}}@media (min-width:1200px){.visible-lg-inline{display:inline!important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media (min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.eot b/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.eot new file mode 100644 index 0000000..b93a495 Binary files /dev/null and b/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.eot differ diff --git a/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.svg b/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.svg new file mode 100644 index 0000000..94fb549 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.svg @@ -0,0 +1,288 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.ttf b/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.ttf new file mode 100644 index 0000000..1413fc6 Binary files /dev/null and b/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.ttf differ diff --git a/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff b/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff new file mode 100644 index 0000000..9e61285 Binary files /dev/null and b/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff differ diff --git a/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff2 b/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff2 new file mode 100644 index 0000000..64539b5 Binary files /dev/null and b/lab/01-rewards-db/src/main/java/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff2 differ diff --git a/lab/01-rewards-db/src/main/java/static/resources/styles/styles.css b/lab/01-rewards-db/src/main/java/static/resources/styles/styles.css new file mode 100644 index 0000000..311a6c2 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/static/resources/styles/styles.css @@ -0,0 +1,10 @@ +body { + padding-top: 40px; + padding-bottom: 40px; +} + +.footer { + padding-top: 19px; + color: #777; + border-top: 1px solid #e5e5e5; +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/css/bootstrap-theme.min.css b/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/css/bootstrap-theme.min.css new file mode 100644 index 0000000..61358b1 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/css/bootstrap-theme.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */.btn-danger,.btn-default,.btn-info,.btn-primary,.btn-success,.btn-warning{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning:active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-danger.disabled,.btn-danger[disabled],.btn-default.disabled,.btn-default[disabled],.btn-info.disabled,.btn-info[disabled],.btn-primary.disabled,.btn-primary[disabled],.btn-success.disabled,.btn-success[disabled],.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-danger,fieldset[disabled] .btn-default,fieldset[disabled] .btn-info,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-success,fieldset[disabled] .btn-warning{-webkit-box-shadow:none;box-shadow:none}.btn-danger .badge,.btn-default .badge,.btn-info .badge,.btn-primary .badge,.btn-success .badge,.btn-warning .badge{text-shadow:none}.btn.active,.btn:active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-o-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#e0e0e0));background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc}.btn-default:focus,.btn-default:hover{background-color:#e0e0e0;background-position:0 -15px}.btn-default.active,.btn-default:active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#e0e0e0;background-image:none}.btn-primary{background-image:-webkit-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-o-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#265a88));background-image:linear-gradient(to bottom,#337ab7 0,#265a88 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#245580}.btn-primary:focus,.btn-primary:hover{background-color:#265a88;background-position:0 -15px}.btn-primary.active,.btn-primary:active{background-color:#265a88;border-color:#245580}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#265a88;background-image:none}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#419641));background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:focus,.btn-success:hover{background-color:#419641;background-position:0 -15px}.btn-success.active,.btn-success:active{background-color:#419641;border-color:#3e8f3e}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#419641;background-image:none}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#2aabd2));background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:focus,.btn-info:hover{background-color:#2aabd2;background-position:0 -15px}.btn-info.active,.btn-info:active{background-color:#2aabd2;border-color:#28a4c9}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#2aabd2;background-image:none}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#eb9316));background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:focus,.btn-warning:hover{background-color:#eb9316;background-position:0 -15px}.btn-warning.active,.btn-warning:active{background-color:#eb9316;border-color:#e38d13}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#eb9316;background-image:none}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c12e2a));background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:focus,.btn-danger:hover{background-color:#c12e2a;background-position:0 -15px}.btn-danger.active,.btn-danger:active{background-color:#c12e2a;border-color:#b92c28}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#c12e2a;background-image:none}.img-thumbnail,.thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{background-color:#2e6da4;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-o-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#f8f8f8));background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-o-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dbdbdb),to(#e2e2e2));background-image:linear-gradient(to bottom,#dbdbdb 0,#e2e2e2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-o-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#3c3c3c),to(#222));background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-o-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#080808),to(#0f0f0f));background-image:linear-gradient(to bottom,#080808 0,#0f0f0f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#c8e5bc));background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);background-repeat:repeat-x;border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#b9def0));background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);background-repeat:repeat-x;border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#f8efc0));background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);background-repeat:repeat-x;border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-o-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#e7c3c3));background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);background-repeat:repeat-x;border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#ebebeb),to(#f5f5f5));background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x}.progress-bar{background-image:-webkit-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-o-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#286090));background-image:linear-gradient(to bottom,#337ab7 0,#286090 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);background-repeat:repeat-x}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#449d44));background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);background-repeat:repeat-x}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#31b0d5));background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);background-repeat:repeat-x}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#ec971f));background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);background-repeat:repeat-x}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c9302c));background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);background-repeat:repeat-x}.progress-bar-striped{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{text-shadow:0 -1px 0 #286090;background-image:-webkit-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2b669a));background-image:linear-gradient(to bottom,#337ab7 0,#2b669a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);background-repeat:repeat-x;border-color:#2b669a}.list-group-item.active .badge,.list-group-item.active:focus .badge,.list-group-item.active:hover .badge{text-shadow:none}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#d0e9c6));background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);background-repeat:repeat-x}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#c4e3f3));background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);background-repeat:repeat-x}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#faf2cc));background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);background-repeat:repeat-x}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-o-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#ebcccc));background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);background-repeat:repeat-x}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#e8e8e8),to(#f5f5f5));background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x;border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/css/bootstrap.min.css b/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/css/bootstrap.min.css new file mode 100644 index 0000000..d65c66b --- /dev/null +++ b/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/css/bootstrap.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0;font-size:2em}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;-webkit-box-shadow:none!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\2a"}.glyphicon-plus:before{content:"\2b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:focus,a:hover{color:#23527c;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{display:inline-block;max-width:100%;height:auto;padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role=button]{cursor:pointer}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{padding:.2em;background-color:#fcf8e3}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:focus,a.text-primary:hover{color:#286090}.text-success{color:#3c763d}a.text-success:focus,a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:focus,a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:focus,a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:focus,a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:focus,a.bg-primary:hover{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ol,ul{margin-top:0;margin-bottom:10px}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;margin-left:-5px;list-style:none}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}dl{margin-top:0;margin-bottom:20px}dd,dt{line-height:1.42857143}dt{font-weight:700}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;overflow:hidden;clear:left;text-align:right;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[data-original-title],abbr[title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote .small:before,blockquote footer:before,blockquote small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;text-align:right;border-right:5px solid #eee;border-left:0}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:''}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;font-weight:700;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{margin-right:-15px;margin-left:-15px}.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*=col-]{position:static;display:table-column;float:none}table td[class*=col-],table th[class*=col-]{position:static;display:table-cell;float:none}.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{min-height:.01%;overflow-x:auto}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:700}input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=file]:focus,input[type=checkbox]:focus,input[type=radio]:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type=search]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=time].form-control,input[type=datetime-local].form-control,input[type=month].form-control{line-height:34px}.input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{line-height:30px}.input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox label,.radio label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-top:4px\9;margin-left:-20px}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;font-weight:400;vertical-align:middle;cursor:pointer}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{cursor:not-allowed}.checkbox.disabled label,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .radio label{cursor:not-allowed}.form-control-static{min-height:34px;padding-top:7px;padding-bottom:7px;margin-bottom:0}.form-control-static.input-lg,.form-control-static.input-sm{padding-right:0;padding-left:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.has-success .form-control-feedback{color:#3c763d}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.form-horizontal .control-label{padding-top:7px;margin-bottom:0;text-align:right}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:14.33px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:400;line-height:1.42857143;text-align:center;white-space:nowrap;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-image:none;border:1px solid transparent;border-radius:4px}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#333;text-decoration:none}.btn.active,.btn:active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none;opacity:.65}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default.focus,.btn-default:focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.dropdown-toggle.btn-default.focus,.open>.dropdown-toggle.btn-default:focus,.open>.dropdown-toggle.btn-default:hover{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.dropdown-toggle.btn-primary.focus,.open>.dropdown-toggle.btn-primary:focus,.open>.dropdown-toggle.btn-primary:hover{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.dropdown-toggle.btn-success.focus,.open>.dropdown-toggle.btn-success:focus,.open>.dropdown-toggle.btn-success:hover{color:#fff;background-color:#398439;border-color:#255625}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.dropdown-toggle.btn-info.focus,.open>.dropdown-toggle.btn-info:focus,.open>.dropdown-toggle.btn-info:hover{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.dropdown-toggle.btn-warning.focus,.open>.dropdown-toggle.btn-warning:focus,.open>.dropdown-toggle.btn-warning:hover{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.dropdown-toggle.btn-danger.focus,.open>.dropdown-toggle.btn-danger:focus,.open>.dropdown-toggle.btn-danger:hover{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{font-weight:400;color:#337ab7;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease;-webkit-transition-duration:.35s;-o-transition-duration:.35s;transition-duration:.35s;-webkit-transition-property:height,visibility;-o-transition-property:height,visibility;transition-property:height,visibility}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown,.dropup{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;text-align:left;list-style:none;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175)}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;background-color:#337ab7;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{right:0;left:auto}.dropdown-menu-left{right:auto;left:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{content:"";border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{right:auto;left:0}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:0;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-top-right-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-right:0;padding-left:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group .form-control,.input-group-addon,.input-group-btn{display:table-cell}.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{padding-right:15px;padding-left:15px;overflow-x:visible;-webkit-overflow-scrolling:touch;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1)}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-right:0;padding-left:0}}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;height:50px;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-top:8px;margin-right:15px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{padding:10px 15px;margin-top:8px;margin-right:-15px;margin-bottom:8px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1)}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-left-radius:0;border-top-right-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}}@media (min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#555;background-color:#e7e7e7}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#080808}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.42857143;color:#337ab7;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:4px;border-bottom-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:3;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:2;color:#fff;cursor:default;background-color:#337ab7;border-color:#337ab7}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:6px;border-bottom-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:3px;border-bottom-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:focus,.label-success[href]:hover{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:middle;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#eee}.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-right:60px;padding-left:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail a>img,.thumbnail>img{margin-right:auto;margin-left:auto}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#337ab7}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:700}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{overflow:hidden;zoom:1}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{color:#555;text-decoration:none;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#777;cursor:not-allowed;background-color:#eee}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-left-radius:3px;border-top-right-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-left-radius:3px;border-top-right-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px;padding-left:15px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;filter:alpha(opacity=20);opacity:.2}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;filter:alpha(opacity=50);opacity:.5}button.close{-webkit-appearance:none;padding:0;cursor:pointer;background:0 0;border:0}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;overflow:hidden;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transition:-webkit-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out;-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%)}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;outline:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5)}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{filter:alpha(opacity=0);opacity:0}.modal-backdrop.in{filter:alpha(opacity=50);opacity:.5}.modal-header{min-height:16.43px;padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:12px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;filter:alpha(opacity=0);opacity:0;line-break:auto}.tooltip.in{filter:alpha(opacity=90);opacity:.9}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px;bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2);line-break:auto}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{padding:8px 14px;margin:0;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{content:"";border-width:10px}.popover.top>.arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,.25);border-bottom-width:0}.popover.top>.arrow:after{bottom:1px;margin-left:-10px;content:" ";border-top-color:#fff;border-bottom-width:0}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,.25);border-left-width:0}.popover.right>.arrow:after{bottom:-10px;left:1px;content:" ";border-right-color:#fff;border-left-width:0}.popover.bottom>.arrow{top:-11px;left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25)}.popover.bottom>.arrow:after{top:1px;margin-left:-10px;content:" ";border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{right:1px;bottom:-10px;content:" ";border-right-width:0;border-left-color:#fff}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>a>img,.carousel-inner>.item>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.active.right,.carousel-inner>.item.next{left:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{left:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{left:0;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);filter:alpha(opacity=50);opacity:.5}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001)));background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);background-repeat:repeat-x}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5)));background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);background-repeat:repeat-x}.carousel-control:focus,.carousel-control:hover{color:#fff;text-decoration:none;filter:alpha(opacity=90);outline:0;opacity:.9}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;z-index:5;display:inline-block;margin-top:-10px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;font-family:serif;line-height:1}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000\9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-15px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-15px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-15px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{display:table;content:" "}.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-lg,.visible-md,.visible-sm,.visible-xs{display:none!important}.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}}@media (max-width:767px){.visible-xs-block{display:block!important}}@media (max-width:767px){.visible-xs-inline{display:inline!important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}}@media (min-width:1200px){.visible-lg-block{display:block!important}}@media (min-width:1200px){.visible-lg-inline{display:inline!important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media (min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.eot b/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.eot new file mode 100644 index 0000000..b93a495 Binary files /dev/null and b/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.eot differ diff --git a/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.svg b/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.svg new file mode 100644 index 0000000..94fb549 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.svg @@ -0,0 +1,288 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.ttf b/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.ttf new file mode 100644 index 0000000..1413fc6 Binary files /dev/null and b/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.ttf differ diff --git a/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff b/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff new file mode 100644 index 0000000..9e61285 Binary files /dev/null and b/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff differ diff --git a/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff2 b/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff2 new file mode 100644 index 0000000..64539b5 Binary files /dev/null and b/lab/01-rewards-db/src/main/java/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff2 differ diff --git a/lab/01-rewards-db/src/main/java/static/styles/styles.css b/lab/01-rewards-db/src/main/java/static/styles/styles.css new file mode 100644 index 0000000..311a6c2 --- /dev/null +++ b/lab/01-rewards-db/src/main/java/static/styles/styles.css @@ -0,0 +1,10 @@ +body { + padding-top: 40px; + padding-bottom: 40px; +} + +.footer { + padding-top: 19px; + color: #777; + border-top: 1px solid #e5e5e5; +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/resources/rewards/testdb/data.sql b/lab/01-rewards-db/src/main/resources/rewards/testdb/data.sql new file mode 100644 index 0000000..28a87cc --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/rewards/testdb/data.sql @@ -0,0 +1,78 @@ + +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (17, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) + values ('1234567890', 'AppleBees', .08, 'A'); diff --git a/lab/01-rewards-db/src/main/resources/rewards/testdb/mysql_data.sql b/lab/01-rewards-db/src/main/resources/rewards/testdb/mysql_data.sql new file mode 100644 index 0000000..a96f90a --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/rewards/testdb/mysql_data.sql @@ -0,0 +1,77 @@ +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (21, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (1, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (1, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (4, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (4, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (4, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (4, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (9, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (9, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (9, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (13, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (13, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (16, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (16, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (16, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (18, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) + values ('1234567890', 'AppleBees', .08, 'A'); diff --git a/lab/01-rewards-db/src/main/resources/rewards/testdb/mysql_schema.sql b/lab/01-rewards-db/src/main/resources/rewards/testdb/mysql_schema.sql new file mode 100644 index 0000000..54e0c1f --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/rewards/testdb/mysql_schema.sql @@ -0,0 +1,16 @@ +drop table if exists T_ACCOUNT_BENEFICIARY; +drop table if exists T_ACCOUNT_CREDIT_CARD; +drop table if exists T_ACCOUNT; +drop table if exists T_RESTAURANT; +drop table if exists T_REWARD; +drop table if exists DUAL_REWARD_CONFIRMATION_NUMBER; + +create table T_ACCOUNT (ID INT NOT NULL AUTO_INCREMENT, PRIMARY KEY(ID), NUMBER varchar(9), NAME varchar(50)); +create table T_ACCOUNT_CREDIT_CARD (ID INT NOT NULL AUTO_INCREMENT, PRIMARY KEY(ID), ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID INT NOT NULL AUTO_INCREMENT, PRIMARY KEY(ID), ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2), SAVINGS decimal(8,2)); +create table T_RESTAURANT (ID INT NOT NULL AUTO_INCREMENT, PRIMARY KEY(ID), MERCHANT_NUMBER varchar(10), NAME varchar(80), BENEFIT_PERCENTAGE decimal(3,2), BENEFIT_AVAILABILITY_POLICY varchar(1)); +create table T_REWARD (ID INT NOT NULL AUTO_INCREMENT, PRIMARY KEY(ID), CONFIRMATION_NUMBER varchar(25), REWARD_AMOUNT decimal(8,2), REWARD_DATE date, ACCOUNT_NUMBER varchar(9), DINING_AMOUNT decimal(8,2), DINING_MERCHANT_NUMBER varchar(10), DINING_DATE date); + +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO int); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + diff --git a/lab/01-rewards-db/src/main/resources/rewards/testdb/schema.sql b/lab/01-rewards-db/src/main/resources/rewards/testdb/schema.sql new file mode 100644 index 0000000..b0324fa --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/rewards/testdb/schema.sql @@ -0,0 +1,20 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, unique(NUMBER)); +create table T_ACCOUNT_CREDIT_CARD (ID integer identity primary key, ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(3,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_CREDIT_CARD add constraint FK_ACCOUNT_CREDIT_CARD foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/resources/static/images/spring-logo.svg b/lab/01-rewards-db/src/main/resources/static/images/spring-logo.svg new file mode 100644 index 0000000..8a63385 --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/static/images/spring-logo.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + diff --git a/lab/01-rewards-db/src/main/resources/static/images/spring-trans-dark.png b/lab/01-rewards-db/src/main/resources/static/images/spring-trans-dark.png new file mode 100644 index 0000000..bdb7792 Binary files /dev/null and b/lab/01-rewards-db/src/main/resources/static/images/spring-trans-dark.png differ diff --git a/lab/01-rewards-db/src/main/resources/static/resources/images/spring-logo.svg b/lab/01-rewards-db/src/main/resources/static/resources/images/spring-logo.svg new file mode 100644 index 0000000..8a63385 --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/static/resources/images/spring-logo.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + diff --git a/lab/01-rewards-db/src/main/resources/static/resources/images/spring-trans-dark.png b/lab/01-rewards-db/src/main/resources/static/resources/images/spring-trans-dark.png new file mode 100644 index 0000000..bdb7792 Binary files /dev/null and b/lab/01-rewards-db/src/main/resources/static/resources/images/spring-trans-dark.png differ diff --git a/lab/01-rewards-db/src/main/resources/static/resources/readme.txt b/lab/01-rewards-db/src/main/resources/static/resources/readme.txt new file mode 100644 index 0000000..61b80ad --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/static/resources/readme.txt @@ -0,0 +1,25 @@ +The only purpose of this /static/resources folder and files is to make +is slightly easier to serve up resources for boot and non-boot resources +in the same course. + +In the mvc labs, the pages will refer to the resources under "/resources/styles" +and "/resources/images", and a special resource handler is needed to translate +"/resources/**" into "classpath:/static/*". This works beautifully. + +But boot handles static classpath resources even more beautifully. +Boot finds resources automatically from /static, /resources, etc. etc. with no special +mapping needed. When we copy the pages that work in the non-boot labs to the boot lab, +references to "/resources/styles" would need to be located under "/static/resources/styles" + +We could easily resolve this by adding a resource handler to the boot app like this: + +@Configuration +public class MvcConfig extends WebMvcConfigurerAdapter { + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/resources/**") + .addResourceLocations("classpath:/static/"); + } +} + but this would be a bit challenging to explain, and would give the impression that +boot requires more special configuration than a normal Spring app, which is certainly +not the impression we wish to leave students with. \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/css/bootstrap-theme.min.css b/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/css/bootstrap-theme.min.css new file mode 100644 index 0000000..61358b1 --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/css/bootstrap-theme.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */.btn-danger,.btn-default,.btn-info,.btn-primary,.btn-success,.btn-warning{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning:active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-danger.disabled,.btn-danger[disabled],.btn-default.disabled,.btn-default[disabled],.btn-info.disabled,.btn-info[disabled],.btn-primary.disabled,.btn-primary[disabled],.btn-success.disabled,.btn-success[disabled],.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-danger,fieldset[disabled] .btn-default,fieldset[disabled] .btn-info,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-success,fieldset[disabled] .btn-warning{-webkit-box-shadow:none;box-shadow:none}.btn-danger .badge,.btn-default .badge,.btn-info .badge,.btn-primary .badge,.btn-success .badge,.btn-warning .badge{text-shadow:none}.btn.active,.btn:active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-o-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#e0e0e0));background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc}.btn-default:focus,.btn-default:hover{background-color:#e0e0e0;background-position:0 -15px}.btn-default.active,.btn-default:active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#e0e0e0;background-image:none}.btn-primary{background-image:-webkit-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-o-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#265a88));background-image:linear-gradient(to bottom,#337ab7 0,#265a88 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#245580}.btn-primary:focus,.btn-primary:hover{background-color:#265a88;background-position:0 -15px}.btn-primary.active,.btn-primary:active{background-color:#265a88;border-color:#245580}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#265a88;background-image:none}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#419641));background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:focus,.btn-success:hover{background-color:#419641;background-position:0 -15px}.btn-success.active,.btn-success:active{background-color:#419641;border-color:#3e8f3e}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#419641;background-image:none}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#2aabd2));background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:focus,.btn-info:hover{background-color:#2aabd2;background-position:0 -15px}.btn-info.active,.btn-info:active{background-color:#2aabd2;border-color:#28a4c9}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#2aabd2;background-image:none}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#eb9316));background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:focus,.btn-warning:hover{background-color:#eb9316;background-position:0 -15px}.btn-warning.active,.btn-warning:active{background-color:#eb9316;border-color:#e38d13}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#eb9316;background-image:none}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c12e2a));background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:focus,.btn-danger:hover{background-color:#c12e2a;background-position:0 -15px}.btn-danger.active,.btn-danger:active{background-color:#c12e2a;border-color:#b92c28}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#c12e2a;background-image:none}.img-thumbnail,.thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{background-color:#2e6da4;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-o-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#f8f8f8));background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-o-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dbdbdb),to(#e2e2e2));background-image:linear-gradient(to bottom,#dbdbdb 0,#e2e2e2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-o-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#3c3c3c),to(#222));background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-o-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#080808),to(#0f0f0f));background-image:linear-gradient(to bottom,#080808 0,#0f0f0f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#c8e5bc));background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);background-repeat:repeat-x;border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#b9def0));background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);background-repeat:repeat-x;border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#f8efc0));background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);background-repeat:repeat-x;border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-o-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#e7c3c3));background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);background-repeat:repeat-x;border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#ebebeb),to(#f5f5f5));background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x}.progress-bar{background-image:-webkit-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-o-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#286090));background-image:linear-gradient(to bottom,#337ab7 0,#286090 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);background-repeat:repeat-x}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#449d44));background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);background-repeat:repeat-x}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#31b0d5));background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);background-repeat:repeat-x}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#ec971f));background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);background-repeat:repeat-x}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c9302c));background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);background-repeat:repeat-x}.progress-bar-striped{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{text-shadow:0 -1px 0 #286090;background-image:-webkit-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2b669a));background-image:linear-gradient(to bottom,#337ab7 0,#2b669a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);background-repeat:repeat-x;border-color:#2b669a}.list-group-item.active .badge,.list-group-item.active:focus .badge,.list-group-item.active:hover .badge{text-shadow:none}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#d0e9c6));background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);background-repeat:repeat-x}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#c4e3f3));background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);background-repeat:repeat-x}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#faf2cc));background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);background-repeat:repeat-x}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-o-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#ebcccc));background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);background-repeat:repeat-x}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#e8e8e8),to(#f5f5f5));background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x;border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/css/bootstrap.min.css b/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/css/bootstrap.min.css new file mode 100644 index 0000000..d65c66b --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/css/bootstrap.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0;font-size:2em}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;-webkit-box-shadow:none!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\2a"}.glyphicon-plus:before{content:"\2b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:focus,a:hover{color:#23527c;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{display:inline-block;max-width:100%;height:auto;padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role=button]{cursor:pointer}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{padding:.2em;background-color:#fcf8e3}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:focus,a.text-primary:hover{color:#286090}.text-success{color:#3c763d}a.text-success:focus,a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:focus,a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:focus,a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:focus,a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:focus,a.bg-primary:hover{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ol,ul{margin-top:0;margin-bottom:10px}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;margin-left:-5px;list-style:none}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}dl{margin-top:0;margin-bottom:20px}dd,dt{line-height:1.42857143}dt{font-weight:700}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;overflow:hidden;clear:left;text-align:right;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[data-original-title],abbr[title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote .small:before,blockquote footer:before,blockquote small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;text-align:right;border-right:5px solid #eee;border-left:0}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:''}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;font-weight:700;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{margin-right:-15px;margin-left:-15px}.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*=col-]{position:static;display:table-column;float:none}table td[class*=col-],table th[class*=col-]{position:static;display:table-cell;float:none}.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{min-height:.01%;overflow-x:auto}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:700}input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=file]:focus,input[type=checkbox]:focus,input[type=radio]:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type=search]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=time].form-control,input[type=datetime-local].form-control,input[type=month].form-control{line-height:34px}.input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{line-height:30px}.input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox label,.radio label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-top:4px\9;margin-left:-20px}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;font-weight:400;vertical-align:middle;cursor:pointer}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{cursor:not-allowed}.checkbox.disabled label,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .radio label{cursor:not-allowed}.form-control-static{min-height:34px;padding-top:7px;padding-bottom:7px;margin-bottom:0}.form-control-static.input-lg,.form-control-static.input-sm{padding-right:0;padding-left:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.has-success .form-control-feedback{color:#3c763d}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.form-horizontal .control-label{padding-top:7px;margin-bottom:0;text-align:right}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:14.33px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:400;line-height:1.42857143;text-align:center;white-space:nowrap;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-image:none;border:1px solid transparent;border-radius:4px}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#333;text-decoration:none}.btn.active,.btn:active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none;opacity:.65}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default.focus,.btn-default:focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.dropdown-toggle.btn-default.focus,.open>.dropdown-toggle.btn-default:focus,.open>.dropdown-toggle.btn-default:hover{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.dropdown-toggle.btn-primary.focus,.open>.dropdown-toggle.btn-primary:focus,.open>.dropdown-toggle.btn-primary:hover{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.dropdown-toggle.btn-success.focus,.open>.dropdown-toggle.btn-success:focus,.open>.dropdown-toggle.btn-success:hover{color:#fff;background-color:#398439;border-color:#255625}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.dropdown-toggle.btn-info.focus,.open>.dropdown-toggle.btn-info:focus,.open>.dropdown-toggle.btn-info:hover{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.dropdown-toggle.btn-warning.focus,.open>.dropdown-toggle.btn-warning:focus,.open>.dropdown-toggle.btn-warning:hover{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.dropdown-toggle.btn-danger.focus,.open>.dropdown-toggle.btn-danger:focus,.open>.dropdown-toggle.btn-danger:hover{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{font-weight:400;color:#337ab7;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease;-webkit-transition-duration:.35s;-o-transition-duration:.35s;transition-duration:.35s;-webkit-transition-property:height,visibility;-o-transition-property:height,visibility;transition-property:height,visibility}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown,.dropup{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;text-align:left;list-style:none;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175)}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;background-color:#337ab7;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{right:0;left:auto}.dropdown-menu-left{right:auto;left:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{content:"";border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{right:auto;left:0}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:0;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-top-right-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-right:0;padding-left:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group .form-control,.input-group-addon,.input-group-btn{display:table-cell}.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{padding-right:15px;padding-left:15px;overflow-x:visible;-webkit-overflow-scrolling:touch;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1)}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-right:0;padding-left:0}}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;height:50px;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-top:8px;margin-right:15px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{padding:10px 15px;margin-top:8px;margin-right:-15px;margin-bottom:8px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1)}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-left-radius:0;border-top-right-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}}@media (min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#555;background-color:#e7e7e7}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#080808}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.42857143;color:#337ab7;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:4px;border-bottom-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:3;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:2;color:#fff;cursor:default;background-color:#337ab7;border-color:#337ab7}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:6px;border-bottom-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:3px;border-bottom-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:focus,.label-success[href]:hover{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:middle;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#eee}.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-right:60px;padding-left:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail a>img,.thumbnail>img{margin-right:auto;margin-left:auto}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#337ab7}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:700}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{overflow:hidden;zoom:1}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{color:#555;text-decoration:none;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#777;cursor:not-allowed;background-color:#eee}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-left-radius:3px;border-top-right-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-left-radius:3px;border-top-right-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px;padding-left:15px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;filter:alpha(opacity=20);opacity:.2}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;filter:alpha(opacity=50);opacity:.5}button.close{-webkit-appearance:none;padding:0;cursor:pointer;background:0 0;border:0}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;overflow:hidden;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transition:-webkit-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out;-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%)}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;outline:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5)}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{filter:alpha(opacity=0);opacity:0}.modal-backdrop.in{filter:alpha(opacity=50);opacity:.5}.modal-header{min-height:16.43px;padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:12px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;filter:alpha(opacity=0);opacity:0;line-break:auto}.tooltip.in{filter:alpha(opacity=90);opacity:.9}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px;bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2);line-break:auto}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{padding:8px 14px;margin:0;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{content:"";border-width:10px}.popover.top>.arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,.25);border-bottom-width:0}.popover.top>.arrow:after{bottom:1px;margin-left:-10px;content:" ";border-top-color:#fff;border-bottom-width:0}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,.25);border-left-width:0}.popover.right>.arrow:after{bottom:-10px;left:1px;content:" ";border-right-color:#fff;border-left-width:0}.popover.bottom>.arrow{top:-11px;left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25)}.popover.bottom>.arrow:after{top:1px;margin-left:-10px;content:" ";border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{right:1px;bottom:-10px;content:" ";border-right-width:0;border-left-color:#fff}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>a>img,.carousel-inner>.item>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.active.right,.carousel-inner>.item.next{left:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{left:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{left:0;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);filter:alpha(opacity=50);opacity:.5}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001)));background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);background-repeat:repeat-x}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5)));background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);background-repeat:repeat-x}.carousel-control:focus,.carousel-control:hover{color:#fff;text-decoration:none;filter:alpha(opacity=90);outline:0;opacity:.9}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;z-index:5;display:inline-block;margin-top:-10px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;font-family:serif;line-height:1}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000\9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-15px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-15px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-15px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{display:table;content:" "}.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-lg,.visible-md,.visible-sm,.visible-xs{display:none!important}.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}}@media (max-width:767px){.visible-xs-block{display:block!important}}@media (max-width:767px){.visible-xs-inline{display:inline!important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}}@media (min-width:1200px){.visible-lg-block{display:block!important}}@media (min-width:1200px){.visible-lg-inline{display:inline!important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media (min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.eot b/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.eot new file mode 100644 index 0000000..b93a495 Binary files /dev/null and b/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.eot differ diff --git a/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.svg b/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.svg new file mode 100644 index 0000000..94fb549 --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.svg @@ -0,0 +1,288 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.ttf b/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.ttf new file mode 100644 index 0000000..1413fc6 Binary files /dev/null and b/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.ttf differ diff --git a/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff b/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff new file mode 100644 index 0000000..9e61285 Binary files /dev/null and b/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff differ diff --git a/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff2 b/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff2 new file mode 100644 index 0000000..64539b5 Binary files /dev/null and b/lab/01-rewards-db/src/main/resources/static/resources/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff2 differ diff --git a/lab/01-rewards-db/src/main/resources/static/resources/styles/styles.css b/lab/01-rewards-db/src/main/resources/static/resources/styles/styles.css new file mode 100644 index 0000000..311a6c2 --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/static/resources/styles/styles.css @@ -0,0 +1,10 @@ +body { + padding-top: 40px; + padding-bottom: 40px; +} + +.footer { + padding-top: 19px; + color: #777; + border-top: 1px solid #e5e5e5; +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/css/bootstrap-theme.min.css b/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/css/bootstrap-theme.min.css new file mode 100644 index 0000000..61358b1 --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/css/bootstrap-theme.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */.btn-danger,.btn-default,.btn-info,.btn-primary,.btn-success,.btn-warning{text-shadow:0 -1px 0 rgba(0,0,0,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 1px rgba(0,0,0,.075)}.btn-danger.active,.btn-danger:active,.btn-default.active,.btn-default:active,.btn-info.active,.btn-info:active,.btn-primary.active,.btn-primary:active,.btn-success.active,.btn-success:active,.btn-warning.active,.btn-warning:active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-danger.disabled,.btn-danger[disabled],.btn-default.disabled,.btn-default[disabled],.btn-info.disabled,.btn-info[disabled],.btn-primary.disabled,.btn-primary[disabled],.btn-success.disabled,.btn-success[disabled],.btn-warning.disabled,.btn-warning[disabled],fieldset[disabled] .btn-danger,fieldset[disabled] .btn-default,fieldset[disabled] .btn-info,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-success,fieldset[disabled] .btn-warning{-webkit-box-shadow:none;box-shadow:none}.btn-danger .badge,.btn-default .badge,.btn-info .badge,.btn-primary .badge,.btn-success .badge,.btn-warning .badge{text-shadow:none}.btn.active,.btn:active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-o-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#e0e0e0));background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#ffe0e0e0', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc}.btn-default:focus,.btn-default:hover{background-color:#e0e0e0;background-position:0 -15px}.btn-default.active,.btn-default:active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#e0e0e0;background-image:none}.btn-primary{background-image:-webkit-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-o-linear-gradient(top,#337ab7 0,#265a88 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#265a88));background-image:linear-gradient(to bottom,#337ab7 0,#265a88 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff265a88', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#245580}.btn-primary:focus,.btn-primary:hover{background-color:#265a88;background-position:0 -15px}.btn-primary.active,.btn-primary:active{background-color:#265a88;border-color:#245580}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#265a88;background-image:none}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#419641));background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff419641', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#3e8f3e}.btn-success:focus,.btn-success:hover{background-color:#419641;background-position:0 -15px}.btn-success.active,.btn-success:active{background-color:#419641;border-color:#3e8f3e}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#419641;background-image:none}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#2aabd2));background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff2aabd2', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#28a4c9}.btn-info:focus,.btn-info:hover{background-color:#2aabd2;background-position:0 -15px}.btn-info.active,.btn-info:active{background-color:#2aabd2;border-color:#28a4c9}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#2aabd2;background-image:none}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#eb9316));background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffeb9316', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#e38d13}.btn-warning:focus,.btn-warning:hover{background-color:#eb9316;background-position:0 -15px}.btn-warning.active,.btn-warning:active{background-color:#eb9316;border-color:#e38d13}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#eb9316;background-image:none}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c12e2a));background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc12e2a', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-color:#b92c28}.btn-danger:focus,.btn-danger:hover{background-color:#c12e2a;background-position:0 -15px}.btn-danger.active,.btn-danger:active{background-color:#c12e2a;border-color:#b92c28}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#c12e2a;background-image:none}.img-thumbnail,.thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{background-color:#2e6da4;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-o-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fff),to(#f8f8f8));background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff', endColorstr='#fff8f8f8', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075);box-shadow:inset 0 1px 0 rgba(255,255,255,.15),0 1px 5px rgba(0,0,0,.075)}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-o-linear-gradient(top,#dbdbdb 0,#e2e2e2 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dbdbdb),to(#e2e2e2));background-image:linear-gradient(to bottom,#dbdbdb 0,#e2e2e2 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdbdbdb', endColorstr='#ffe2e2e2', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.075);box-shadow:inset 0 3px 9px rgba(0,0,0,.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-o-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#3c3c3c),to(#222));background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c', endColorstr='#ff222222', GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);background-repeat:repeat-x;border-radius:4px}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.open>a{background-image:-webkit-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-o-linear-gradient(top,#080808 0,#0f0f0f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#080808),to(#0f0f0f));background-image:linear-gradient(to bottom,#080808 0,#0f0f0f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff080808', endColorstr='#ff0f0f0f', GradientType=0);background-repeat:repeat-x;-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,.25);box-shadow:inset 0 3px 9px rgba(0,0,0,.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,.25)}.navbar-fixed-bottom,.navbar-fixed-top,.navbar-static-top{border-radius:0}@media (max-width:767px){.navbar .navbar-nav .open .dropdown-menu>.active>a,.navbar .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}}.alert{text-shadow:0 1px 0 rgba(255,255,255,.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05);box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 2px rgba(0,0,0,.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#c8e5bc));background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffc8e5bc', GradientType=0);background-repeat:repeat-x;border-color:#b2dba1}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#b9def0));background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffb9def0', GradientType=0);background-repeat:repeat-x;border-color:#9acfea}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#f8efc0));background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fff8efc0', GradientType=0);background-repeat:repeat-x;border-color:#f5e79e}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-o-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#e7c3c3));background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffe7c3c3', GradientType=0);background-repeat:repeat-x;border-color:#dca7a7}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#ebebeb),to(#f5f5f5));background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x}.progress-bar{background-image:-webkit-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-o-linear-gradient(top,#337ab7 0,#286090 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#286090));background-image:linear-gradient(to bottom,#337ab7 0,#286090 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff286090', GradientType=0);background-repeat:repeat-x}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-o-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5cb85c),to(#449d44));background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c', endColorstr='#ff449d44', GradientType=0);background-repeat:repeat-x}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-o-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#5bc0de),to(#31b0d5));background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de', endColorstr='#ff31b0d5', GradientType=0);background-repeat:repeat-x}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-o-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f0ad4e),to(#ec971f));background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e', endColorstr='#ffec971f', GradientType=0);background-repeat:repeat-x}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-o-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9534f),to(#c9302c));background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f', endColorstr='#ffc9302c', GradientType=0);background-repeat:repeat-x}.progress-bar-striped{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,.075);box-shadow:0 1px 2px rgba(0,0,0,.075)}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{text-shadow:0 -1px 0 #286090;background-image:-webkit-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2b669a 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2b669a));background-image:linear-gradient(to bottom,#337ab7 0,#2b669a 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2b669a', GradientType=0);background-repeat:repeat-x;border-color:#2b669a}.list-group-item.active .badge,.list-group-item.active:focus .badge,.list-group-item.active:hover .badge{text-shadow:none}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,.05);box-shadow:0 1px 2px rgba(0,0,0,.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-o-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),to(#e8e8e8));background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#ffe8e8e8', GradientType=0);background-repeat:repeat-x}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-o-linear-gradient(top,#337ab7 0,#2e6da4 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#337ab7),to(#2e6da4));background-image:linear-gradient(to bottom,#337ab7 0,#2e6da4 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff337ab7', endColorstr='#ff2e6da4', GradientType=0);background-repeat:repeat-x}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-o-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#dff0d8),to(#d0e9c6));background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8', endColorstr='#ffd0e9c6', GradientType=0);background-repeat:repeat-x}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-o-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#d9edf7),to(#c4e3f3));background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7', endColorstr='#ffc4e3f3', GradientType=0);background-repeat:repeat-x}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-o-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#fcf8e3),to(#faf2cc));background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3', endColorstr='#fffaf2cc', GradientType=0);background-repeat:repeat-x}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-o-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#f2dede),to(#ebcccc));background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede', endColorstr='#ffebcccc', GradientType=0);background-repeat:repeat-x}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-o-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:-webkit-gradient(linear,left top,left bottom,from(#e8e8e8),to(#f5f5f5));background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8', endColorstr='#fff5f5f5', GradientType=0);background-repeat:repeat-x;border-color:#dcdcdc;-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 3px rgba(0,0,0,.05),0 1px 0 rgba(255,255,255,.1)} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/css/bootstrap.min.css b/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/css/bootstrap.min.css new file mode 100644 index 0000000..d65c66b --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/css/bootstrap.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap v3.3.5 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + *//*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0;font-size:2em}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;-webkit-box-shadow:none!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\2a"}.glyphicon-plus:before{content:"\2b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:focus,a:hover{color:#23527c;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{display:inline-block;max-width:100%;height:auto;padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}[role=button]{cursor:pointer}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{padding:.2em;background-color:#fcf8e3}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:focus,a.text-primary:hover{color:#286090}.text-success{color:#3c763d}a.text-success:focus,a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:focus,a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:focus,a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:focus,a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:focus,a.bg-primary:hover{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ol,ul{margin-top:0;margin-bottom:10px}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;margin-left:-5px;list-style:none}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}dl{margin-top:0;margin-bottom:20px}dd,dt{line-height:1.42857143}dt{font-weight:700}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;overflow:hidden;clear:left;text-align:right;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[data-original-title],abbr[title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote .small:before,blockquote footer:before,blockquote small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;text-align:right;border-right:5px solid #eee;border-left:0}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:''}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;font-weight:700;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{margin-right:-15px;margin-left:-15px}.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*=col-]{position:static;display:table-column;float:none}table td[class*=col-],table th[class*=col-]{position:static;display:table-cell;float:none}.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{min-height:.01%;overflow-x:auto}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:700}input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=file]:focus,input[type=checkbox]:focus,input[type=radio]:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1}.form-control[disabled],fieldset[disabled] .form-control{cursor:not-allowed}textarea.form-control{height:auto}input[type=search]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=time].form-control,input[type=datetime-local].form-control,input[type=month].form-control{line-height:34px}.input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{line-height:30px}.input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox label,.radio label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-top:4px\9;margin-left:-20px}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{position:relative;display:inline-block;padding-left:20px;margin-bottom:0;font-weight:400;vertical-align:middle;cursor:pointer}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{cursor:not-allowed}.checkbox.disabled label,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .radio label{cursor:not-allowed}.form-control-static{min-height:34px;padding-top:7px;padding-bottom:7px;margin-bottom:0}.form-control-static.input-lg,.form-control-static.input-sm{padding-right:0;padding-left:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.form-group-sm select.form-control{height:30px;line-height:30px}.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto}.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.form-group-lg select.form-control{height:46px;line-height:46px}.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto}.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.has-success .form-control-feedback{color:#3c763d}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.form-horizontal .control-label{padding-top:7px;margin-bottom:0;text-align:right}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:14.33px;font-size:18px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px}}.btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:400;line-height:1.42857143;text-align:center;white-space:nowrap;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-image:none;border:1px solid transparent;border-radius:4px}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#333;text-decoration:none}.btn.active,.btn:active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none;opacity:.65}a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default.focus,.btn-default:focus{color:#333;background-color:#e6e6e6;border-color:#8c8c8c}.btn-default:hover{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.dropdown-toggle.btn-default.focus,.open>.dropdown-toggle.btn-default:focus,.open>.dropdown-toggle.btn-default:hover{color:#333;background-color:#d4d4d4;border-color:#8c8c8c}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#286090;border-color:#122b40}.btn-primary:hover{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.dropdown-toggle.btn-primary.focus,.open>.dropdown-toggle.btn-primary:focus,.open>.dropdown-toggle.btn-primary:hover{color:#fff;background-color:#204d74;border-color:#122b40}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#449d44;border-color:#255625}.btn-success:hover{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.dropdown-toggle.btn-success.focus,.open>.dropdown-toggle.btn-success:focus,.open>.dropdown-toggle.btn-success:hover{color:#fff;background-color:#398439;border-color:#255625}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#31b0d5;border-color:#1b6d85}.btn-info:hover{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.dropdown-toggle.btn-info.focus,.open>.dropdown-toggle.btn-info:focus,.open>.dropdown-toggle.btn-info:hover{color:#fff;background-color:#269abc;border-color:#1b6d85}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#ec971f;border-color:#985f0d}.btn-warning:hover{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.dropdown-toggle.btn-warning.focus,.open>.dropdown-toggle.btn-warning:focus,.open>.dropdown-toggle.btn-warning:hover{color:#fff;background-color:#d58512;border-color:#985f0d}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c9302c;border-color:#761c19}.btn-danger:hover{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.dropdown-toggle.btn-danger.focus,.open>.dropdown-toggle.btn-danger:focus,.open>.dropdown-toggle.btn-danger:hover{color:#fff;background-color:#ac2925;border-color:#761c19}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{font-weight:400;color:#337ab7;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none}.collapse.in{display:block}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease;-webkit-transition-duration:.35s;-o-transition-duration:.35s;transition-duration:.35s;-webkit-transition-property:height,visibility;-o-transition-property:height,visibility;transition-property:height,visibility}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown,.dropup{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;text-align:left;list-style:none;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175)}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;background-color:#337ab7;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{right:0;left:auto}.dropdown-menu-left{right:auto;left:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{content:"";border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{right:auto;left:0}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:0;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-top-right-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-right:0;padding-left:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group .form-control,.input-group-addon,.input-group-btn{display:table-cell}.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{padding-right:15px;padding-left:15px;overflow-x:visible;-webkit-overflow-scrolling:touch;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1)}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-right:0;padding-left:0}}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;height:50px;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-top:8px;margin-right:15px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{padding:10px 15px;margin-top:8px;margin-right:-15px;margin-bottom:8px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1)}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-left-radius:0;border-top-right-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}}@media (min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#555;background-color:#e7e7e7}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#080808}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.42857143;color:#337ab7;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:4px;border-bottom-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:3;color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:2;color:#fff;cursor:default;background-color:#337ab7;border-color:#337ab7}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:6px;border-bottom-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:3px;border-bottom-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:focus,.label-success[href]:hover{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:middle;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;color:inherit;background-color:#eee}.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px}.container .jumbotron,.container-fluid .jumbotron{padding-right:60px;padding-left:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail a>img,.thumbnail>img{margin-right:auto;margin-left:auto}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#337ab7}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:700}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{overflow:hidden;zoom:1}.media-body{width:10000px}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item,button.list-group-item{color:#555}a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{color:#555;text-decoration:none;background-color:#f5f5f5}button.list-group-item{width:100%;text-align:left}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#777;cursor:not-allowed;background-color:#eee}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-left-radius:3px;border-top-right-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-left-radius:3px;border-top-right-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px;padding-left:15px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;filter:alpha(opacity=20);opacity:.2}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;filter:alpha(opacity=50);opacity:.5}button.close{-webkit-appearance:none;padding:0;cursor:pointer;background:0 0;border:0}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;overflow:hidden;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transition:-webkit-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out;-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%)}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;outline:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5)}.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000}.modal-backdrop.fade{filter:alpha(opacity=0);opacity:0}.modal-backdrop.in{filter:alpha(opacity=50);opacity:.5}.modal-header{min-height:16.43px;padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:12px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;filter:alpha(opacity=0);opacity:0;line-break:auto}.tooltip.in{filter:alpha(opacity=90);opacity:.9}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px;bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;font-style:normal;font-weight:400;line-height:1.42857143;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal;white-space:normal;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2);line-break:auto}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{padding:8px 14px;margin:0;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{content:"";border-width:10px}.popover.top>.arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,.25);border-bottom-width:0}.popover.top>.arrow:after{bottom:1px;margin-left:-10px;content:" ";border-top-color:#fff;border-bottom-width:0}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,.25);border-left-width:0}.popover.right>.arrow:after{bottom:-10px;left:1px;content:" ";border-right-color:#fff;border-left-width:0}.popover.bottom>.arrow{top:-11px;left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25)}.popover.bottom>.arrow:after{top:1px;margin-left:-10px;content:" ";border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{right:1px;bottom:-10px;content:" ";border-right-width:0;border-left-color:#fff}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>a>img,.carousel-inner>.item>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;perspective:1000px}.carousel-inner>.item.active.right,.carousel-inner>.item.next{left:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{left:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{left:0;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);filter:alpha(opacity=50);opacity:.5}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001)));background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);background-repeat:repeat-x}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5)));background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);background-repeat:repeat-x}.carousel-control:focus,.carousel-control:hover{color:#fff;text-decoration:none;filter:alpha(opacity=90);outline:0;opacity:.9}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;z-index:5;display:inline-block;margin-top:-10px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;font-family:serif;line-height:1}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000\9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-15px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-15px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-15px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{display:table;content:" "}.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-lg,.visible-md,.visible-sm,.visible-xs{display:none!important}.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table!important}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}}@media (max-width:767px){.visible-xs-block{display:block!important}}@media (max-width:767px){.visible-xs-inline{display:inline!important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table!important}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table!important}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table!important}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}}@media (min-width:1200px){.visible-lg-block{display:block!important}}@media (min-width:1200px){.visible-lg-inline{display:inline!important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media (min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table!important}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}} \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.eot b/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.eot new file mode 100644 index 0000000..b93a495 Binary files /dev/null and b/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.eot differ diff --git a/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.svg b/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.svg new file mode 100644 index 0000000..94fb549 --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.svg @@ -0,0 +1,288 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.ttf b/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.ttf new file mode 100644 index 0000000..1413fc6 Binary files /dev/null and b/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.ttf differ diff --git a/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff b/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff new file mode 100644 index 0000000..9e61285 Binary files /dev/null and b/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff differ diff --git a/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff2 b/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff2 new file mode 100644 index 0000000..64539b5 Binary files /dev/null and b/lab/01-rewards-db/src/main/resources/static/styles/bootstrap/3.3.5/fonts/glyphicons-halflings-regular.woff2 differ diff --git a/lab/01-rewards-db/src/main/resources/static/styles/styles.css b/lab/01-rewards-db/src/main/resources/static/styles/styles.css new file mode 100644 index 0000000..311a6c2 --- /dev/null +++ b/lab/01-rewards-db/src/main/resources/static/styles/styles.css @@ -0,0 +1,10 @@ +body { + padding-top: 40px; + padding-bottom: 40px; +} + +.footer { + padding-top: 19px; + color: #777; + border-top: 1px solid #e5e5e5; +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/test/java/accounts/internal/AbstractAccountManagerTests.java b/lab/01-rewards-db/src/test/java/accounts/internal/AbstractAccountManagerTests.java new file mode 100644 index 0000000..37931df --- /dev/null +++ b/lab/01-rewards-db/src/test/java/accounts/internal/AbstractAccountManagerTests.java @@ -0,0 +1,162 @@ +package accounts.internal; + +import accounts.AccountManager; +import ch.qos.logback.classic.Level; +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Integration test for an account manager implementation. + *

+ * Tests application behavior to verify the AccountManager and the database + * mapping of its domain objects are correct. The implementation of the + * AccountManager class is irrelevant to these tests and so is the testing + * environment (stubbing, manual or Spring-driven configuration). + */ +public abstract class AbstractAccountManagerTests { + + protected final Logger logger; + + @Autowired + protected AccountManager accountManager; + + public AbstractAccountManagerTests() { + logger = LoggerFactory.getLogger(getClass()); + if (logger instanceof ch.qos.logback.classic.Logger logger1) + logger1.setLevel(Level.INFO); + } + + /** + * Quick test to check the right profile is being used. + */ + @Test + public abstract void testProfile(); + + /** + * How many accounts are defined for use in the test? + * + * @return Number of accounts available. + */ + protected abstract int getNumAccountsExpected(); + + /** + * Used to log current transactional status. + */ + protected abstract void showStatus(); + + @Test + @Transactional + public void testGetAllAccounts() { + showStatus(); + List accounts = accountManager.getAllAccounts(); + assertEquals(getNumAccountsExpected(), accounts.size(), "Wrong number of accounts"); + } + + @Test + @Transactional + public void getAccount() { + Account account = accountManager.getAccount(0L); + // assert the returned account contains what you expect given the state + // of the database + assertNotNull(account, "account should never be null"); + assertEquals(account.getEntityId().longValue(), 0L, "wrong entity id"); + assertEquals("123456789", account.getNumber(), "wrong account number"); + assertEquals("Keith and Keri Donald", account.getName(), "wrong name"); + assertEquals(2, account.getBeneficiaries().size(), "wrong beneficiary collection size"); + + Beneficiary b1 = account.getBeneficiary("Annabelle"); + assertNotNull(b1, "Annabelle should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b1.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b1.getAllocationPercentage(), "wrong allocation percentage"); + + Beneficiary b2 = account.getBeneficiary("Corgan"); + assertNotNull(b2, "Corgan should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b2.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b2.getAllocationPercentage(), "wrong allocation percentage"); + } + + @Test + @Transactional + public void addAccount() { + // FIX ME: - Manual configuration fails, Spring driven integration tests work + // OK. + if (accountManager instanceof JpaAccountManager + && this.getClass().getAnnotation(ContextConfiguration.class) == null) + return; + + // long newAccountId = getNumAccountsExpected(); + + Account account = new Account("1010101", "Test"); + account.addBeneficiary("Bene1", Percentage.valueOf("100%")); + + showStatus(); + Account newAccount = accountManager.save(account); + + assertEquals(getNumAccountsExpected() + 1, accountManager.getAllAccounts().size(), "Wrong number of accounts"); + + newAccount = accountManager.getAccount(newAccount.getEntityId()); + assertNotNull(newAccount, "Did not find new account"); + assertEquals("Test", newAccount.getName(), "Did not save account"); + assertEquals(1, newAccount.getBeneficiaries().size(), "Did not save beneficiary"); + } + + @Test + @Transactional + public void updateAccount() { + Account oldAccount = accountManager.getAccount(0L); + oldAccount.setName("Ben Hale"); + accountManager.update(oldAccount); + Account newAccount = accountManager.getAccount(0L); + assertEquals("Ben Hale", newAccount.getName(), "Did not persist the name change"); + } + + @Test + @Transactional + public void updateAccountBeneficiaries() { + Map allocationPercentages = new HashMap<>(); + allocationPercentages.put("Annabelle", Percentage.valueOf("25%")); + allocationPercentages.put("Corgan", Percentage.valueOf("75%")); + accountManager.updateBeneficiaryAllocationPercentages(0L, allocationPercentages); + Account account = accountManager.getAccount(0L); + assertEquals(Percentage.valueOf("25%"), + account.getBeneficiary("Annabelle").getAllocationPercentage(), "Invalid adjusted percentage"); + assertEquals(Percentage.valueOf("75%"), + account.getBeneficiary("Corgan").getAllocationPercentage(), "Invalid adjusted percentage"); + } + + @Test + @Transactional + public void addBeneficiary() { + accountManager.addBeneficiary(0L, "Ben"); + Account account = accountManager.getAccount(0L); + assertEquals( 3, account.getBeneficiaries().size(), "Should only have three beneficiaries"); + } + + @Test + @Transactional + public void removeBeneficiary() { + Map allocationPercentages = new HashMap<>(); + allocationPercentages.put("Corgan", Percentage.oneHundred()); + accountManager.removeBeneficiary(0L, "Annabelle", allocationPercentages); + Account account = accountManager.getAccount(0L); + assertEquals(1, account.getBeneficiaries().size(), "Should only have one beneficiary"); + assertEquals(Percentage.oneHundred(), + account.getBeneficiary("Corgan").getAllocationPercentage(), "Corgan should now have 100% allocation"); + } + +} diff --git a/lab/01-rewards-db/src/test/java/accounts/internal/AbstractDatabaseAccountManagerTests.java b/lab/01-rewards-db/src/test/java/accounts/internal/AbstractDatabaseAccountManagerTests.java new file mode 100644 index 0000000..0d88a27 --- /dev/null +++ b/lab/01-rewards-db/src/test/java/accounts/internal/AbstractDatabaseAccountManagerTests.java @@ -0,0 +1,55 @@ +package accounts.internal; + +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import utils.TransactionUtils; + +import javax.sql.DataSource; + +/** + * Supports transactional testing of AccountManager implementation in both a + * manual and a Spring-configured environment. + *

+ * Manual configuration, allows testing of an AccountManager implementation + * without Spring. + *

+ * Automated configuration using a class annotated with @ContextConfiguration + * tests both the implementation of AccountManager and the Spring configuration + * files. + */ +public abstract class AbstractDatabaseAccountManagerTests extends AbstractAccountManagerTests { + + protected static int numAccountsInDb = -1; + + @Autowired + protected PlatformTransactionManager transactionManager; + + @Autowired + protected DataSource dataSource; + + protected TransactionUtils transactionUtils; + + @Override + protected void showStatus() { + logger.info("TRANSACTION IS : " + transactionUtils.getCurrentTransaction()); + } + + @BeforeEach + public void setUp() throws Exception { + // The number of test accounts in the database - a static variable so we only do + // this once. + if (numAccountsInDb == -1) + numAccountsInDb = new JdbcTemplate(dataSource).queryForObject("SELECT count(*) FROM T_Account", + Integer.class); + + // Setup the transaction utility class + transactionUtils = new TransactionUtils(transactionManager); + } + + @Override + protected int getNumAccountsExpected() { + return numAccountsInDb; + } +} diff --git a/lab/01-rewards-db/src/test/java/accounts/internal/AccountManagerTests.java b/lab/01-rewards-db/src/test/java/accounts/internal/AccountManagerTests.java new file mode 100644 index 0000000..ed1bb7e --- /dev/null +++ b/lab/01-rewards-db/src/test/java/accounts/internal/AccountManagerTests.java @@ -0,0 +1,22 @@ +package accounts.internal; + +public class AccountManagerTests extends AbstractAccountManagerTests { + + public AccountManagerTests() { + accountManager = new StubAccountManager(); + } + + @Override + public void testProfile() { + } + + @Override + protected int getNumAccountsExpected() { + return StubAccountManager.NUM_ACCOUNTS_IN_STUB; + } + + @Override + protected void showStatus() { + } + +} diff --git a/lab/01-rewards-db/src/test/java/accounts/internal/JpaAccountManagerIntegrationTests.java b/lab/01-rewards-db/src/test/java/accounts/internal/JpaAccountManagerIntegrationTests.java new file mode 100644 index 0000000..d11ff63 --- /dev/null +++ b/lab/01-rewards-db/src/test/java/accounts/internal/JpaAccountManagerIntegrationTests.java @@ -0,0 +1,30 @@ +package accounts.internal; + +import config.AppConfig; +import config.DbConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Spring-driven integration test for the JPA-based account manager + * implementation. Verifies that the JpaAccountManager works with its underlying + * components. + */ +@ActiveProfiles("jpa") +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = { AppConfig.class, DbConfig.class }) +public class JpaAccountManagerIntegrationTests extends AbstractDatabaseAccountManagerTests { + + @Test + @Override + public void testProfile() { + assertEquals("JPA", accountManager.getInfo(), "JPA expected but found " + accountManager.getInfo()); + } + +} diff --git a/lab/01-rewards-db/src/test/java/accounts/internal/JpaAccountManagerManualIntegrationTests.java b/lab/01-rewards-db/src/test/java/accounts/internal/JpaAccountManagerManualIntegrationTests.java new file mode 100644 index 0000000..aa4ae74 --- /dev/null +++ b/lab/01-rewards-db/src/test/java/accounts/internal/JpaAccountManagerManualIntegrationTests.java @@ -0,0 +1,51 @@ +package accounts.internal; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import utils.DataManagementSetup; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Manually configured integration test (not using Spring) for the JPA-based + * account manager implementation. Verifies that the JpaAccountManager works + * with its underlying components. + */ +public class JpaAccountManagerManualIntegrationTests extends AbstractDatabaseAccountManagerTests { + + final DataManagementSetup dataManagementSetup = new DataManagementSetup(); + + public JpaAccountManagerManualIntegrationTests() { + setupForTest(); + } + + @Test + @Override + public void testProfile() { + assertTrue(accountManager instanceof JpaAccountManager, "JPA expected"); + logger.info("JPA with Hibernate"); + } + + @BeforeEach + @Override + public void setUp() throws Exception { + super.setUp(); + transactionUtils.beginTransaction(); + } + + @AfterEach + public void tearDown() { + transactionUtils.rollbackTransaction(); + } + + private void setupForTest() { + dataSource = dataManagementSetup.getDataSource(); + + JpaAccountManager accountManager = new JpaAccountManager(); + accountManager.setEntityManager(dataManagementSetup.createEntityManager()); + this.accountManager = accountManager; + transactionManager = dataManagementSetup.getTransactionManager(); + } + +} diff --git a/lab/01-rewards-db/src/test/java/rewards/internal/account/AbstractAccountRepositoryTests.java b/lab/01-rewards-db/src/test/java/rewards/internal/account/AbstractAccountRepositoryTests.java new file mode 100644 index 0000000..aff0887 --- /dev/null +++ b/lab/01-rewards-db/src/test/java/rewards/internal/account/AbstractAccountRepositoryTests.java @@ -0,0 +1,57 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.*; + + +/** + * Unit tests for an account repository implementation. + *

+ * Tests application behavior to verify the AccountRepository and the database + * mapping of its domain objects are correct. The implementation of the + * AccountRepository class is irrelevant to these tests and so is the testing + * environment (stubbing, manual or Spring-driven configuration). + */ +public abstract class AbstractAccountRepositoryTests { + + @Autowired + protected AccountRepository accountRepository; + + @Test + public abstract void testProfile(); + + @Test + @Transactional + public void findByCreditCard() { + Account account = accountRepository + .findByCreditCard("1234123412341234"); + + // assert the returned account contains what you expect given the state + // of the database + assertNotNull(account, "account should never be null"); + assertEquals(Long.valueOf(0), account.getEntityId(), "wrong entity id"); + assertEquals("123456789", account.getNumber(), "wrong account number"); + assertEquals("Keith and Keri Donald", account.getName(), "wrong name"); + assertEquals(2, account + .getBeneficiaries().size(), "wrong beneficiary collection size"); + + Beneficiary b1 = account.getBeneficiary("Annabelle"); + assertNotNull(b1, "Annabelle should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), + b1.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), + b1.getAllocationPercentage(), "wrong allocation percentage"); + + Beneficiary b2 = account.getBeneficiary("Corgan"); + assertNotNull(b2, "Corgan should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), + b2.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), + b2.getAllocationPercentage(), "wrong allocation percentage"); + } +} diff --git a/lab/01-rewards-db/src/test/java/rewards/internal/account/AccountTests.java b/lab/01-rewards-db/src/test/java/rewards/internal/account/AccountTests.java new file mode 100644 index 0000000..d2a53ae --- /dev/null +++ b/lab/01-rewards-db/src/test/java/rewards/internal/account/AccountTests.java @@ -0,0 +1,53 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.Test; +import rewards.AccountContribution; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the Account class that verify Account behavior works in isolation. + */ +public class AccountTests { + + private final Account account = new Account("1", "Keith and Keri Donald"); + + @Test + public void accountIsValid() { + // setup account with a valid set of beneficiaries to prepare for testing + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + assertTrue(account.isValid()); + } + + @Test + public void accountIsInvalidWithNoBeneficiaries() { + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreOver100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreUnder100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("25%")); + assertFalse(account.isValid()); + } + + @Test + public void makeContribution() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("100.00")); + assertEquals(contribution.getAmount(), MonetaryAmount.valueOf("100.00")); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/test/java/rewards/internal/account/JpaAccountRepositoryIntegrationTests.java b/lab/01-rewards-db/src/test/java/rewards/internal/account/JpaAccountRepositoryIntegrationTests.java new file mode 100644 index 0000000..a2fbe07 --- /dev/null +++ b/lab/01-rewards-db/src/test/java/rewards/internal/account/JpaAccountRepositoryIntegrationTests.java @@ -0,0 +1,30 @@ +package rewards.internal.account; + +import config.AppConfig; +import config.DbConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.junit.jupiter.api.Assertions.*; + + +/** + * Integration test for the JPA based account repository implementation. + * Verifies that the JpaAccountRepository works with its underlying components + * and that Spring is configuring things properly. + */ +@ActiveProfiles("jpa") +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = { AppConfig.class, DbConfig.class }) +public class JpaAccountRepositoryIntegrationTests extends AbstractAccountRepositoryTests { + + @Test + @Override + public void testProfile() { + assertEquals(JpaAccountRepository.INFO, accountRepository.getInfo(), "JPA expected but found " + accountRepository.getInfo()); + } + +} diff --git a/lab/01-rewards-db/src/test/java/rewards/internal/account/JpaAccountRepositoryTests.java b/lab/01-rewards-db/src/test/java/rewards/internal/account/JpaAccountRepositoryTests.java new file mode 100644 index 0000000..fddd4e9 --- /dev/null +++ b/lab/01-rewards-db/src/test/java/rewards/internal/account/JpaAccountRepositoryTests.java @@ -0,0 +1,49 @@ +package rewards.internal.account; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import utils.DataManagementSetup; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Manually configured integration test for the JPA based account repository + * implementation.Tests repository behavior and verifies the Account JPA mapping + * is correct. + */ +public class JpaAccountRepositoryTests extends AbstractAccountRepositoryTests { + + private PlatformTransactionManager transactionManager; + + private TransactionStatus transactionStatus; + + @BeforeEach + public void setUp() { + DataManagementSetup dataManagementSetup = new DataManagementSetup(); + + JpaAccountRepository accountRepository = new JpaAccountRepository(); + accountRepository.setEntityManager(dataManagementSetup.createEntityManager()); + this.accountRepository = accountRepository; + + // begin a transaction + transactionManager = dataManagementSetup.getTransactionManager(); + transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition()); + } + + @Test + @Override + public void testProfile() { + assertTrue(accountRepository instanceof JpaAccountRepository, "JPA expected"); + } + + @AfterEach + public void tearDown() { + // rollback the transaction to avoid corrupting other tests + if (transactionManager != null) + transactionManager.rollback(transactionStatus); + } +} diff --git a/lab/01-rewards-db/src/test/java/rewards/internal/restaurant/AbstractRestaurantRepositoryTests.java b/lab/01-rewards-db/src/test/java/rewards/internal/restaurant/AbstractRestaurantRepositoryTests.java new file mode 100644 index 0000000..651b7e8 --- /dev/null +++ b/lab/01-rewards-db/src/test/java/rewards/internal/restaurant/AbstractRestaurantRepositoryTests.java @@ -0,0 +1,43 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.*; + + +/** + * Unit tests for a restaurant repository implementation. + *

+ * Tests application behavior to verify the RestaurantRepository and the + * database mapping of its domain objects are correct. The implementation of the + * RestaurantRepository class is irrelevant to these tests and so is the testing + * environment (stubbing, manual or Spring-driven configuration). + */ +public abstract class AbstractRestaurantRepositoryTests { + + @Autowired + protected RestaurantRepository restaurantRepository; + + @Test + public abstract void testProfile(); + + @Test + @Transactional + public void findRestaurantByMerchantNumber() { + Restaurant restaurant = restaurantRepository + .findByMerchantNumber("1234567890"); + assertNotNull(restaurant, "the restaurant should never be null"); + assertEquals("1234567890", + restaurant.getNumber(), "the merchant number is wrong"); + assertEquals("AppleBees", restaurant.getName(), "the name is wrong"); + assertEquals( + Percentage.valueOf("8%"), restaurant.getBenefitPercentage(), "the benefitPercentage is wrong"); + assertEquals( + AlwaysAvailable.INSTANCE, + restaurant.getBenefitAvailabilityPolicy(), "the benefit availability policy is wrong"); + } + +} diff --git a/lab/01-rewards-db/src/test/java/rewards/internal/restaurant/JpaRestaurantRepositoryIntegrationTests.java b/lab/01-rewards-db/src/test/java/rewards/internal/restaurant/JpaRestaurantRepositoryIntegrationTests.java new file mode 100644 index 0000000..267ecdb --- /dev/null +++ b/lab/01-rewards-db/src/test/java/rewards/internal/restaurant/JpaRestaurantRepositoryIntegrationTests.java @@ -0,0 +1,30 @@ +package rewards.internal.restaurant; + +import config.AppConfig; +import config.DbConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration test for the JPA-based restaurant repository implementation. + * Verifies that the JpaRestaurantRepository works with its underlying + * components and that Spring is configuring things properly. + */ +@ActiveProfiles("jpa") +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = {AppConfig.class, DbConfig.class}) +public class JpaRestaurantRepositoryIntegrationTests extends AbstractRestaurantRepositoryTests { + + @Test + @Override + public void testProfile() { + assertEquals(JpaRestaurantRepository.INFO, restaurantRepository.getInfo(), "JPA expected but found " + restaurantRepository.getInfo()); + } + +} diff --git a/lab/01-rewards-db/src/test/java/rewards/internal/restaurant/JpaRestaurantRepositoryTests.java b/lab/01-rewards-db/src/test/java/rewards/internal/restaurant/JpaRestaurantRepositoryTests.java new file mode 100644 index 0000000..bf2f2ff --- /dev/null +++ b/lab/01-rewards-db/src/test/java/rewards/internal/restaurant/JpaRestaurantRepositoryTests.java @@ -0,0 +1,50 @@ +package rewards.internal.restaurant; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import utils.DataManagementSetup; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Manually configured integration test for the JPA based restaurant repository + * implementation. Tests repository behavior and verifies the Restaurant JPA + * mapping is correct. + */ +public class JpaRestaurantRepositoryTests extends AbstractRestaurantRepositoryTests { + + private PlatformTransactionManager transactionManager; + + private TransactionStatus transactionStatus; + + @BeforeEach + public void setUp() { + DataManagementSetup dataManagementSetup = new DataManagementSetup(); + + JpaRestaurantRepository restaurantRepository = new JpaRestaurantRepository(); + restaurantRepository.setEntityManager(dataManagementSetup.createEntityManager()); + this.restaurantRepository = restaurantRepository; + + // begin a transaction + transactionManager = dataManagementSetup.getTransactionManager(); + transactionStatus = transactionManager.getTransaction(new DefaultTransactionDefinition()); + } + + @Test + @Override + public void testProfile() { + assertTrue(restaurantRepository instanceof JpaRestaurantRepository, "JPA expected"); + } + + @AfterEach + public void tearDown() { + // rollback the transaction to avoid corrupting other tests + if (transactionManager != null) + transactionManager.rollback(transactionStatus); + } + +} diff --git a/lab/01-rewards-db/src/test/java/rewards/internal/restaurant/RestaurantTests.java b/lab/01-rewards-db/src/test/java/rewards/internal/restaurant/RestaurantTests.java new file mode 100644 index 0000000..450e81c --- /dev/null +++ b/lab/01-rewards-db/src/test/java/rewards/internal/restaurant/RestaurantTests.java @@ -0,0 +1,69 @@ +package rewards.internal.restaurant; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import rewards.Dining; +import rewards.internal.account.Account; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Unit tests for exercising the behavior of the Restaurant aggregate entity. A restaurant calculates a benefit to award + * to an account for dining based on an availability policy and benefit percentage. + */ +public class RestaurantTests { + + private Restaurant restaurant; + + private Account account; + + private Dining dining; + + @BeforeEach + public void setUp() { + // configure the restaurant, the object being tested + restaurant = new Restaurant("1234567890", "AppleBee's"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(true)); + // configure supporting objects needed by the restaurant + account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle"); + dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + } + + @Test + public void testCalcuateBenefitFor() { + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert 8.00 eligible for reward + assertEquals(MonetaryAmount.valueOf("8.00"), benefit); + } + + @Test + public void testNoBenefitAvailable() { + // configure stub that always returns false + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(false)); + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert zero eligible for reward + assertEquals(MonetaryAmount.valueOf("0.00"), benefit); + } + + /** + * A simple "dummy" benefit availability policy containing a single flag used to determine if benefit is available. + * Only useful for testing--a real availability policy might consider many factors such as the day of week of the + * dining, or the account's reward history for the current month. + */ + private static class StubBenefitAvailibilityPolicy implements BenefitAvailabilityPolicy { + + private final boolean isBenefitAvailable; + + public StubBenefitAvailibilityPolicy(boolean isBenefitAvailable) { + this.isBenefitAvailable = isBenefitAvailable; + } + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return isBenefitAvailable; + } + } +} \ No newline at end of file diff --git a/lab/01-rewards-db/src/test/java/rewards/internal/reward/AbstractRewardRepositoryTests.java b/lab/01-rewards-db/src/test/java/rewards/internal/reward/AbstractRewardRepositoryTests.java new file mode 100644 index 0000000..a9229e4 --- /dev/null +++ b/lab/01-rewards-db/src/test/java/rewards/internal/reward/AbstractRewardRepositoryTests.java @@ -0,0 +1,94 @@ +package rewards.internal.reward; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.transaction.annotation.Transactional; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.Account; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import static org.junit.jupiter.api.Assertions.*; + + +/** + * Tests the JDBC reward repository with a test data source to verify data + * access and relational-to-object mapping behavior works as expected. + */ +public abstract class AbstractRewardRepositoryTests { + + @Autowired + protected JdbcRewardRepository rewardRepository; + + @Autowired + protected DataSource dataSource; + + @Test + public abstract void testProfile(); + + @Test + @Transactional + public void createReward() throws SQLException { + Dining dining = Dining.createDining("100.00", "1234123412341234", + "0123456789"); + + Account account = new Account("1", "Keith and Keri Donald"); + account.setEntityId(0L); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + + AccountContribution contribution = account + .makeContribution(MonetaryAmount.valueOf("8.00")); + RewardConfirmation confirmation = rewardRepository.confirmReward( + contribution, dining); + assertNotNull(confirmation, "confirmation should not be null"); + assertNotNull("confirmation number should not be null", + confirmation.getConfirmationNumber()); + assertEquals(contribution, + confirmation.getAccountContribution(), "wrong contribution object"); + verifyRewardInserted(confirmation, dining); + } + + private void verifyRewardInserted(RewardConfirmation confirmation, + Dining dining) throws SQLException { + assertEquals(1, getRewardCount()); + Statement stmt = getCurrentConnection().createStatement(); + ResultSet rs = stmt + .executeQuery("select REWARD_AMOUNT from T_REWARD where CONFIRMATION_NUMBER = '" + + confirmation.getConfirmationNumber() + "'"); + rs.next(); + assertEquals(confirmation.getAccountContribution().getAmount(), + MonetaryAmount.valueOf(rs.getString(1))); + } + + private int getRewardCount() throws SQLException { + Statement stmt = getCurrentConnection().createStatement(); + ResultSet rs = stmt.executeQuery("select count(*) from T_REWARD"); + rs.next(); + return rs.getInt(1); + } + + /** + * Gets the connection behind the current transaction - this allows the + * tests to use the transaction created for the @Transactional test and see + * the changes made. + *

+ * Using a different (new) connection would fail because the T_REWARD table + * is locked by any updates and the queries in verifyRewardInserted + * and getRewardCount can never return. + * + * @return The current connection + */ + private Connection getCurrentConnection() { + return DataSourceUtils.getConnection(dataSource); + } +} diff --git a/lab/01-rewards-db/src/test/java/rewards/internal/reward/JdbcRewardRepositoryIntegrationTests.java b/lab/01-rewards-db/src/test/java/rewards/internal/reward/JdbcRewardRepositoryIntegrationTests.java new file mode 100644 index 0000000..3d2576d --- /dev/null +++ b/lab/01-rewards-db/src/test/java/rewards/internal/reward/JdbcRewardRepositoryIntegrationTests.java @@ -0,0 +1,29 @@ +package rewards.internal.reward; + + +import config.AppConfig; +import config.DbConfig; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.junit.jupiter.api.Assertions.*; + + +/** + * Integration test for the JDBC-based rewards repository implementation. + * Verifies that the JdbcRewardRepository works with its underlying components + * and that Spring is configuring things properly. + */ +@ActiveProfiles("jpa") +@SpringJUnitConfig(classes = {AppConfig.class, DbConfig.class}) +public class JdbcRewardRepositoryIntegrationTests extends + AbstractRewardRepositoryTests { + + @Test + @Override + public void testProfile() { + assertEquals(JdbcRewardRepository.TYPE, rewardRepository.getInfo(), "JDBC expected but found " + rewardRepository.getInfo()); + } + +} diff --git a/lab/01-rewards-db/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java b/lab/01-rewards-db/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java new file mode 100644 index 0000000..3c79091 --- /dev/null +++ b/lab/01-rewards-db/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java @@ -0,0 +1,35 @@ +package rewards.internal.reward; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the JDBC reward repository with a test data source to test repository + * behavior and verifies the Reward JDBC code is correct. + */ +public class JdbcRewardRepositoryTests extends AbstractRewardRepositoryTests { + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + rewardRepository = new JdbcRewardRepository(dataSource); + } + + @Test + @Override + public void testProfile() { + assertTrue( + rewardRepository instanceof JdbcRewardRepository, "JDBC expected"); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder().setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql").build(); + } +} diff --git a/lab/01-rewards-db/src/test/java/utils/DataManagementSetup.java b/lab/01-rewards-db/src/test/java/utils/DataManagementSetup.java new file mode 100644 index 0000000..4f3621a --- /dev/null +++ b/lab/01-rewards-db/src/test/java/utils/DataManagementSetup.java @@ -0,0 +1,101 @@ +package utils; + +import java.util.Properties; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import javax.sql.DataSource; + +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.JpaVendorAdapter; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.vendor.Database; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Setup a JPA data-management layer without using Spring (for testing). + */ +public class DataManagementSetup { + + public static final String DOMAIN_OBJECTS_PARENT_PACKAGE = "rewards.internal"; + + private DataSource dataSource; + private EntityManagerFactory entityManagerFactory; + private PlatformTransactionManager transactionManager; + + public DataManagementSetup() { + } + + private void setup() { + if (dataSource == null) { + dataSource = createTestDataSource(); + entityManagerFactory = createEntityManagerFactory(); + transactionManager = createTransactionManager(); + } + } + + public DataSource getDataSource() { + setup(); + return dataSource; + } + + public EntityManager createEntityManager() { + setup(); + return entityManagerFactory.createEntityManager(); + } + + public PlatformTransactionManager getTransactionManager() { + setup(); + return transactionManager; + } + + // - - - - - - - - - - - - - - - INTERNAL METHODS - - - - - - - - - - - - - - - + + protected DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder().setName("rewards") // + .addScript("/rewards/testdb/schema.sql") // + .addScript("/rewards/testdb/data.sql") // + .build(); + } + + protected JpaTransactionManager createTransactionManager() { + return new JpaTransactionManager(entityManagerFactory); + } + + protected JpaVendorAdapter createVendorAdapter() { + HibernateJpaVendorAdapter adapter = new HibernateJpaVendorAdapter(); + adapter.setDatabase(Database.HSQL); + return adapter; + } + + protected Properties createJpaProperties() { + Properties properties = new Properties(); + // turn on formatted SQL logging (very useful to verify Jpa is + // issuing proper SQL) + properties.setProperty("hibenate.show_sql", "true"); + properties.setProperty("hibernate.format_sql", "true"); + return properties; + } + + protected final EntityManagerFactory createEntityManagerFactory() { + + // Create a FactoryBean to help create a JPA EntityManagerFactory + LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean(); + factoryBean.setDataSource(dataSource); + factoryBean.setJpaVendorAdapter(createVendorAdapter()); + factoryBean.setJpaProperties(createJpaProperties()); + + // Not using persistence unit or persistence.xml, so need to tell + // JPA where to find Entities + factoryBean.setPackagesToScan(DOMAIN_OBJECTS_PARENT_PACKAGE); + + // initialize according to the Spring InitializingBean contract + factoryBean.afterPropertiesSet(); + + // get the created session factory + return factoryBean.getObject(); + } + +} diff --git a/lab/01-rewards-db/src/test/java/utils/TransactionUtils.java b/lab/01-rewards-db/src/test/java/utils/TransactionUtils.java new file mode 100644 index 0000000..f49dcbc --- /dev/null +++ b/lab/01-rewards-db/src/test/java/utils/TransactionUtils.java @@ -0,0 +1,145 @@ +package utils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import ch.qos.logback.classic.Level; + +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.DefaultTransactionDefinition; + +/** + * Utility class for managing Transactions without Spring (in tests). + *

+ * Note: With Spring's @Transactional tests, you don't need any of this + * unless you need to run multiple transactions in a single test. + */ +public class TransactionUtils { + + protected final PlatformTransactionManager transactionManager; + protected final Logger logger; + + private TransactionStatus transactionStatus; + + /** + * Create an instance using any available transaction manager. + * + * @param transactionManager + * In our tests, either a DataSourceTransactionManager or a + * JpaTransactionManager. + */ + public TransactionUtils(PlatformTransactionManager transactionManager) { + assert (transactionManager != null); + + this.transactionManager = transactionManager; + + logger = LoggerFactory.getLogger(getClass()); + if (logger instanceof ch.qos.logback.classic.Logger logger1) + logger1.setLevel(Level.INFO); + + } + + /** + * Begin a new transaction, ensuring one is not running already + * + */ + public void beginTransaction() { + // Make sure no transaction is running + try { + transactionStatus = transactionManager + .getTransaction(new DefaultTransactionDefinition(TransactionDefinition.PROPAGATION_MANDATORY)); + assert (false); // Force an exception - there should be NO transaction + } catch (IllegalTransactionStateException e) { + // Expected behavior, continue + } + + // Begin a new transaction - just checked that there isn't one + transactionStatus = transactionManager + .getTransaction(new DefaultTransactionDefinition(TransactionDefinition.PROPAGATION_REQUIRED)); + + assert (transactionStatus != null); + assert (transactionStatus.isNewTransaction()); + logger.info("NEW " + transactionStatus + " - completed = " + transactionStatus.isCompleted()); + } + + /** + * Rollback the current transaction - there must be one. + * + */ + public void rollbackTransaction() { + // Make sure an exception is running + try { + transactionManager + .getTransaction(new DefaultTransactionDefinition(TransactionDefinition.PROPAGATION_MANDATORY)); + // Expected behavior, continue + } catch (IllegalTransactionStateException e) { + assert (false); // Force an exception - there should be a transaction + } + + // Rollback the transaction to avoid corrupting other tests + logger.info("ROLLBACKK " + transactionStatus); + transactionManager.rollback(transactionStatus); + } + + /** + * Get the current transaction - there should be one. + * + * @return + */ + public TransactionStatus getCurrentTransaction() { + TransactionDefinition definition = new DefaultTransactionDefinition( + DefaultTransactionDefinition.PROPAGATION_MANDATORY); + TransactionStatus transaction = transactionManager.getTransaction(definition); + logger.info("TRANSACTION = " + transaction); + return transaction; + } + + /** + * Get a transaction if there is one or start a new one otherwise. + * + * @return The transaction. + */ + public TransactionStatus getTransaction() { + TransactionDefinition definition = new DefaultTransactionDefinition( + DefaultTransactionDefinition.PROPAGATION_REQUIRED); + TransactionStatus transaction = transactionManager.getTransaction(definition); + logger.info("TRANSACTION = " + transaction); + return transaction; + } + + /** + * Start a brand new transaction - forcing a new one if one exists already + * (using {@link TransactionDefinition#PROPAGATION_REQUIRES_NEW}). + * + * @return + */ + public TransactionStatus getNewTransaction() { + TransactionDefinition definition = new DefaultTransactionDefinition( + DefaultTransactionDefinition.PROPAGATION_REQUIRES_NEW); + TransactionStatus transaction = transactionManager.getTransaction(definition); + logger.info("TRANSACTION = " + transaction); + return transaction; + } + + /** + * Is there a transaction running already? + * + * @return Yes or no. + */ + public boolean transactionExists() { + try { + TransactionStatus transaction = getCurrentTransaction(); + + if (transaction == null) + throw new IllegalStateException("No transaction in progress"); + + logger.info("TRANSACTION EXISTS - new ? " + transaction.isNewTransaction()); + return true; + } catch (Exception e) { + logger.error("NO TRANSACTION: " + e); + return false; + } + } +} diff --git a/lab/10-spring-intro-solution/build.gradle b/lab/10-spring-intro-solution/build.gradle new file mode 100644 index 0000000..af9f0d4 --- /dev/null +++ b/lab/10-spring-intro-solution/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation project(':00-rewards-common') +} diff --git a/lab/10-spring-intro-solution/pom.xml b/lab/10-spring-intro-solution/pom.xml new file mode 100644 index 0000000..94a8496 --- /dev/null +++ b/lab/10-spring-intro-solution/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + 10-spring-intro-solution + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/AccountContribution.java b/lab/10-spring-intro-solution/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/Dining.java b/lab/10-spring-intro-solution/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/RewardConfirmation.java b/lab/10-spring-intro-solution/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/RewardNetwork.java b/lab/10-spring-intro-solution/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/10-spring-intro-solution/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..cb3191e --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,52 @@ +package rewards.internal; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/internal/account/Account.java b/lab/10-spring-intro-solution/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..7b48fa3 --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,141 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. + *

+ * Callers should not attempt to hold on or modify the returned set. This method should only be used transitively; + * for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/internal/account/AccountRepository.java b/lab/10-spring-intro-solution/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..16c6079 --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/internal/account/Beneficiary.java b/lab/10-spring-intro-solution/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/internal/account/package.html b/lab/10-spring-intro-solution/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/internal/package.html b/lab/10-spring-intro-solution/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/10-spring-intro-solution/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..aa642ae --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,79 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on a benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + return dining.getAmount().multiplyBy(benefitPercentage); + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/10-spring-intro-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/internal/restaurant/package.html b/lab/10-spring-intro-solution/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/10-spring-intro-solution/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/internal/reward/package.html b/lab/10-spring-intro-solution/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/10-spring-intro-solution/src/main/java/rewards/package.html b/lab/10-spring-intro-solution/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/10-spring-intro-solution/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/10-spring-intro-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/10-spring-intro-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..86e3e42 --- /dev/null +++ b/lab/10-spring-intro-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,72 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} diff --git a/lab/10-spring-intro-solution/src/test/java/rewards/internal/StubAccountRepository.java b/lab/10-spring-intro-solution/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..625fee6 --- /dev/null +++ b/lab/10-spring-intro-solution/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,41 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new RuntimeException("no account has been found for credit card number " + creditCardNumber); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/10-spring-intro-solution/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/10-spring-intro-solution/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..b812e9e --- /dev/null +++ b/lab/10-spring-intro-solution/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,36 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new RuntimeException("no restaurant has been found for merchant number " + merchantNumber); + } + return restaurant; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro-solution/src/test/java/rewards/internal/StubRewardRepository.java b/lab/10-spring-intro-solution/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..2487aca --- /dev/null +++ b/lab/10-spring-intro-solution/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import java.util.Random; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/10-spring-intro/build.gradle b/lab/10-spring-intro/build.gradle new file mode 100644 index 0000000..af9f0d4 --- /dev/null +++ b/lab/10-spring-intro/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation project(':00-rewards-common') +} diff --git a/lab/10-spring-intro/pom.xml b/lab/10-spring-intro/pom.xml new file mode 100644 index 0000000..31f45c5 --- /dev/null +++ b/lab/10-spring-intro/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + 10-spring-intro + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + diff --git a/lab/10-spring-intro/src/main/java/rewards/AccountContribution.java b/lab/10-spring-intro/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..4638d42 --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private final String accountNumber; + + private final MonetaryAmount amount; + + private final Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private final String beneficiary; + + private final MonetaryAmount amount; + + private final Percentage percentage; + + private final MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro/src/main/java/rewards/Dining.java b/lab/10-spring-intro/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..b7d4609 --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private final MonetaryAmount amount; + + private final String creditCardNumber; + + private final String merchantNumber; + + private final SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a restaurant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a restaurant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a restaurant on the date + * specified. A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro/src/main/java/rewards/RewardConfirmation.java b/lab/10-spring-intro/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..1a55bbd --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private final String confirmationNumber; + + private final AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro/src/main/java/rewards/RewardNetwork.java b/lab/10-spring-intro/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..1d67583 --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/10-spring-intro/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/10-spring-intro/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..df35e2f --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,58 @@ +package rewards.internal; + +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + * + * TODO-00: In this lab, you are going to exercise the following: + * - Understanding internal operations that need to be performed to implement + * "rewardAccountFor" method of the "RewardNetworkImpl" class + * - Writing test code using stub implementations of dependencies + * - Writing both target code and test code without using Spring framework + * + * TODO-01: Review the Rewards Application document (Refer to the lab document) + * TODO-02: Review project dependencies (Refer to the lab document) + * TODO-03: Review Rewards Commons project (Refer to the lab document) + * TODO-04: Review RewardNetwork interface and RewardNetworkImpl class below + * TODO-05: Review the RewardNetworkImpl configuration logic (Refer to the lab document) + * TODO-06: Review sequence diagram (Refer to the lab document) + */ +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + public RewardConfirmation rewardAccountFor(Dining dining) { + // TODO-07: Write code here for rewarding an account according to + // the sequence diagram in the lab document + // TODO-08: Return the corresponding reward confirmation + return null; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro/src/main/java/rewards/internal/account/Account.java b/lab/10-spring-intro/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..c5e9ed8 --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,141 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private final Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. + *

+ * Callers should not attempt to hold on or modify the returned set. This method should only be used transitively; + * for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro/src/main/java/rewards/internal/account/AccountRepository.java b/lab/10-spring-intro/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..7d0ad44 --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/10-spring-intro/src/main/java/rewards/internal/account/Beneficiary.java b/lab/10-spring-intro/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro/src/main/java/rewards/internal/account/package.html b/lab/10-spring-intro/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/10-spring-intro/src/main/java/rewards/internal/package.html b/lab/10-spring-intro/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/10-spring-intro/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/10-spring-intro/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..aa642ae --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,79 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on a benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + return dining.getAmount().multiplyBy(benefitPercentage); + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/10-spring-intro/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..c74f02a --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/10-spring-intro/src/main/java/rewards/internal/restaurant/package.html b/lab/10-spring-intro/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/10-spring-intro/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/10-spring-intro/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..cbcc55d --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/10-spring-intro/src/main/java/rewards/internal/reward/package.html b/lab/10-spring-intro/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/10-spring-intro/src/main/java/rewards/package.html b/lab/10-spring-intro/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/10-spring-intro/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/10-spring-intro/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/10-spring-intro/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..fa09350 --- /dev/null +++ b/lab/10-spring-intro/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,81 @@ +package rewards.internal; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Unit tests for the RewardNetworkImpl application logic. + * Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without + * the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way + * for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they + * are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + // TODO-09: Review the test setup + @BeforeEach + public void setUp() { + // Create stubs to facilitate fast in-memory testing with + // dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // Setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + // TODO-10: Test RewardNetworkImpl class + // - Remove the @Disabled annotation below. + // - Run this JUnit test. Verify it passes. + @Test + @Disabled + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/10-spring-intro/src/test/java/rewards/internal/StubAccountRepository.java b/lab/10-spring-intro/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..625fee6 --- /dev/null +++ b/lab/10-spring-intro/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,41 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new RuntimeException("no account has been found for credit card number " + creditCardNumber); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/10-spring-intro/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/10-spring-intro/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..c7392ea --- /dev/null +++ b/lab/10-spring-intro/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,36 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new RuntimeException("no restaurant has been found for merchant number " + merchantNumber); + } + return restaurant; + } +} \ No newline at end of file diff --git a/lab/10-spring-intro/src/test/java/rewards/internal/StubRewardRepository.java b/lab/10-spring-intro/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..2487aca --- /dev/null +++ b/lab/10-spring-intro/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import java.util.Random; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/build.gradle b/lab/12-javaconfig-dependency-injection-solution/build.gradle new file mode 100644 index 0000000..4d6c184 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/build.gradle @@ -0,0 +1,7 @@ +/* + * This file was generated by the Gradle 'init' task. + */ + +dependencies { + implementation project(':00-rewards-common') +} diff --git a/lab/12-javaconfig-dependency-injection-solution/pom.xml b/lab/12-javaconfig-dependency-injection-solution/pom.xml new file mode 100644 index 0000000..daea2ac --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + 12-javaconfig-dependency-injection-solution + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/config/RewardsConfig.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..49d9626 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/config/RewardsConfig.java @@ -0,0 +1,55 @@ +package config; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import rewards.RewardNetwork; +import rewards.internal.RewardNetworkImpl; +import rewards.internal.account.AccountRepository; +import rewards.internal.account.JdbcAccountRepository; +import rewards.internal.restaurant.JdbcRestaurantRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.JdbcRewardRepository; +import rewards.internal.reward.RewardRepository; + +@Configuration +public class RewardsConfig { + private final DataSource dataSource; + + // As this is the only constructor, @Autowired is not needed. + public RewardsConfig(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Bean + public RewardNetwork rewardNetwork(){ + return new RewardNetworkImpl( + accountRepository(), + restaurantRepository(), + rewardRepository()); + } + + @Bean + public AccountRepository accountRepository(){ + JdbcAccountRepository repository = new JdbcAccountRepository(); + repository.setDataSource(dataSource); + return repository; + } + + @Bean + public RestaurantRepository restaurantRepository(){ + JdbcRestaurantRepository repository = new JdbcRestaurantRepository(); + repository.setDataSource(dataSource); + return repository; + } + + @Bean + public RewardRepository rewardRepository(){ + JdbcRewardRepository repository = new JdbcRewardRepository(); + repository.setDataSource(dataSource); + return repository; + } + +} diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/AccountContribution.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/Dining.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/RewardConfirmation.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/RewardNetwork.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..cb3191e --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,52 @@ +package rewards.internal; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/Account.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..7b48fa3 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,141 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. + *

+ * Callers should not attempt to hold on or modify the returned set. This method should only be used transitively; + * for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/AccountRepository.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..ec0fdde --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,30 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + * + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/Beneficiary.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java new file mode 100644 index 0000000..5181837 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java @@ -0,0 +1,139 @@ +package rewards.internal.account; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.springframework.dao.EmptyResultDataAccessException; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Loads accounts from a data source using the JDBC API. + */ +public class JdbcAccountRepository implements AccountRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to load accounts. + * @param dataSource the data source + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Account findByCreditCard(String creditCardNumber) { + + String sql = """ + select a.ID as ID, a.NUMBER as ACCOUNT_NUMBER, a.NAME as ACCOUNT_NAME, c.NUMBER as CREDIT_CARD_NUMBER, \ + b.NAME as BENEFICIARY_NAME, b.ALLOCATION_PERCENTAGE as BENEFICIARY_ALLOCATION_PERCENTAGE, b.SAVINGS as BENEFICIARY_SAVINGS \ + from T_ACCOUNT a, T_ACCOUNT_CREDIT_CARD c \ + left outer join T_ACCOUNT_BENEFICIARY b \ + on a.ID = b.ACCOUNT_ID \ + where c.ACCOUNT_ID = a.ID and c.NUMBER = ?\ + """; + + Account account = null; + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + try { + conn = dataSource.getConnection(); + ps = conn.prepareStatement(sql); + ps.setString(1, creditCardNumber); + rs = ps.executeQuery(); + account = mapAccount(rs); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred finding by credit card number", e); + } finally { + if (rs != null) { + try { + // Close to prevent database cursor exhaustion + rs.close(); + } catch (SQLException ex) { + } + } + if (ps != null) { + try { + // Close to prevent database cursor exhaustion + ps.close(); + } catch (SQLException ex) { + } + } + if (conn != null) { + try { + // Close to prevent database connection exhaustion + conn.close(); + } catch (SQLException ex) { + } + } + } + return account; + } + + public void updateBeneficiaries(Account account) { + String sql = "update T_ACCOUNT_BENEFICIARY SET SAVINGS = ? where ACCOUNT_ID = ? and NAME = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + for (Beneficiary beneficiary : account.getBeneficiaries()) { + ps.setBigDecimal(1, beneficiary.getSavings().asBigDecimal()); + ps.setLong(2, account.getEntityId()); + ps.setString(3, beneficiary.getName()); + ps.executeUpdate(); + } + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred updating beneficiary savings", e); + } + } + + /** + * Map the rows returned from the join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY to a fully-reconstituted Account + * aggregate. + * @param rs the set of rows returned from the query + * @return the mapped Account aggregate + * @throws SQLException an exception occurred extracting data from the result set + */ + private Account mapAccount(ResultSet rs) throws SQLException { + Account account = null; + while (rs.next()) { + if (account == null) { + String number = rs.getString("ACCOUNT_NUMBER"); + String name = rs.getString("ACCOUNT_NAME"); + account = new Account(number, name); + // set internal entity identifier (primary key) + account.setEntityId(rs.getLong("ID")); + } + Beneficiary b = mapBeneficiary(rs); + if (b != null) { + account.restoreBeneficiary(b); + } + } + if (account == null) { + // no rows returned - throw an empty result exception + throw new EmptyResultDataAccessException(1); + } + return account; + } + + /** + * Maps the beneficiary columns in a single row to an AllocatedBeneficiary object. + * @param rs the result set with its cursor positioned at the current row + * @return an allocated beneficiary + * @throws SQLException an exception occurred extracting data from the result set + */ + private Beneficiary mapBeneficiary(ResultSet rs) throws SQLException { + String name = rs.getString("BENEFICIARY_NAME"); + if (name == null) { + // apparently no beneficiary for this + return null; + } + MonetaryAmount savings = MonetaryAmount.valueOf(rs.getString("BENEFICIARY_SAVINGS")); + Percentage allocationPercentage = Percentage.valueOf(rs.getString("BENEFICIARY_ALLOCATION_PERCENTAGE")); + return new Beneficiary(name, allocationPercentage, savings); + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/package.html b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/package.html b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java new file mode 100644 index 0000000..6e65815 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java @@ -0,0 +1,97 @@ +package rewards.internal.restaurant; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.springframework.dao.EmptyResultDataAccessException; + +import common.money.Percentage; + +/** + * Loads restaurants from a data source using the JDBC API. + */ +public class JdbcRestaurantRepository implements RestaurantRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to load restaurants. + * @param dataSource the data source + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + String sql = "select MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE from T_RESTAURANT where MERCHANT_NUMBER = ?"; + Restaurant restaurant = null; + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + try { + conn = dataSource.getConnection(); + ps = conn.prepareStatement(sql); + ps.setString(1, merchantNumber); + rs = ps.executeQuery(); + advanceToNextRow(rs); + restaurant = mapRestaurant(rs); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred finding by merchant number", e); + } finally { + if (rs != null) { + try { + // Close to prevent database cursor exhaustion + rs.close(); + } catch (SQLException ex) { + } + } + if (ps != null) { + try { + // Close to prevent database cursor exhaustion + ps.close(); + } catch (SQLException ex) { + } + } + if (conn != null) { + try { + // Close to prevent database connection exhaustion + conn.close(); + } catch (SQLException ex) { + } + } + } + return restaurant; + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + * @param rs the result set with its cursor positioned at the current row + */ + private Restaurant mapRestaurant(ResultSet rs) throws SQLException { + // get the row column data + String name = rs.getString("NAME"); + String number = rs.getString("MERCHANT_NUMBER"); + Percentage benefitPercentage = Percentage.valueOf(rs.getString("BENEFIT_PERCENTAGE")); + // map to the object + Restaurant restaurant = new Restaurant(number, name); + restaurant.setBenefitPercentage(benefitPercentage); + return restaurant; + } + + /** + * Advances a ResultSet to the next row and throws an exception if there are no rows. + * @param rs the ResultSet to advance + * @throws EmptyResultDataAccessException if there is no next row + * @throws SQLException + */ + private void advanceToNextRow(ResultSet rs) throws EmptyResultDataAccessException, SQLException { + if (!rs.next()) { + throw new EmptyResultDataAccessException(1); + } + } + +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..aa642ae --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,79 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on a benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + return dining.getAmount().multiplyBy(benefitPercentage); + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage; + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/restaurant/package.html b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..15554d6 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,57 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import javax.sql.DataSource; +import java.sql.*; + +/** + * JDBC implementation of a reward repository that records the result of a reward transaction by inserting a reward + * confirmation record. + */ +public class JdbcRewardRepository implements RewardRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to insert rewards. + * @param dataSource the data source + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + String confirmationNumber = nextConfirmationNumber(); + ps.setString(1, confirmationNumber); + ps.setBigDecimal(2, contribution.getAmount().asBigDecimal()); + ps.setDate(3, new Date(SimpleDate.today().inMilliseconds())); + ps.setString(4, contribution.getAccountNumber()); + ps.setString(5, dining.getMerchantNumber()); + ps.setDate(6, new Date(dining.getDate().inMilliseconds())); + ps.setBigDecimal(7, dining.getAmount().asBigDecimal()); + ps.execute(); + return new RewardConfirmation(confirmationNumber, contribution); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred inserting reward record", e); + } + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getString(1); + } catch (SQLException e) { + throw new RuntimeException("SQL exception getting next confirmation number", e); + } + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/reward/package.html b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/package.html b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/12-javaconfig-dependency-injection-solution/src/test/java/config/RewardsConfigTests.java b/lab/12-javaconfig-dependency-injection-solution/src/test/java/config/RewardsConfigTests.java new file mode 100644 index 0000000..69f6ad8 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/test/java/config/RewardsConfigTests.java @@ -0,0 +1,71 @@ +package config; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; + +import javax.sql.DataSource; + +import org.assertj.core.api.Fail; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import rewards.RewardNetwork; +import rewards.internal.RewardNetworkImpl; +import rewards.internal.account.AccountRepository; +import rewards.internal.account.JdbcAccountRepository; +import rewards.internal.restaurant.JdbcRestaurantRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.JdbcRewardRepository; +import rewards.internal.reward.RewardRepository; + +/** + * Unit test the Spring configuration class to ensure it is creating the right + * beans. + */ +public class RewardsConfigTests { + // Provide a mock for testing + private final DataSource dataSource = Mockito.mock(DataSource.class); + + private final RewardsConfig rewardsConfig = new RewardsConfig(dataSource); + + @Test + public void getBeans() { + RewardNetwork rewardNetwork = rewardsConfig.rewardNetwork(); + assertTrue(rewardNetwork instanceof RewardNetworkImpl); + + AccountRepository accountRepository = rewardsConfig.accountRepository(); + assertTrue(accountRepository instanceof JdbcAccountRepository); + checkDataSource(accountRepository); + + RestaurantRepository restaurantRepository = rewardsConfig.restaurantRepository(); + assertTrue(restaurantRepository instanceof JdbcRestaurantRepository); + checkDataSource(restaurantRepository); + + RewardRepository rewardsRepository = rewardsConfig.rewardRepository(); + assertTrue(rewardsRepository instanceof JdbcRewardRepository); + checkDataSource(rewardsRepository); + } + + /** + * Ensure the data-source is set for the repository. Uses reflection as we do + * not wish to provide a getDataSource() method. + * + * @param repository + */ + private void checkDataSource(Object repository) { + Class repositoryClass = repository.getClass(); + + try { + Field dataSource = repositoryClass.getDeclaredField("dataSource"); + dataSource.setAccessible(true); + assertNotNull(dataSource.get(repository)); + } catch (Exception e) { + String failureMessage = "Unable to validate dataSource in " + repositoryClass.getSimpleName(); + System.out.println(failureMessage); + e.printStackTrace(); + Fail.fail(failureMessage); + } + } +} diff --git a/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/RewardNetworkTests.java b/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..fd10077 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,65 @@ +package rewards; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.context.ApplicationContext; + +import common.money.MonetaryAmount; + +/** + * A system test that verifies the components of the RewardNetwork application work together to reward for dining + * successfully. Uses Spring to bootstrap the application for use in a test environment. + */ +public class RewardNetworkTests { + + /** + * The object being tested. + */ + private RewardNetwork rewardNetwork; + + @BeforeEach + public void setUp() { + // Create the test configuration for the application: + + ApplicationContext context = SpringApplication.run(TestInfrastructureConfig.class); + + // Get the bean to use to invoke the application + rewardNetwork = context.getBean(RewardNetwork.class); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + // this fails if you have selected an account without beneficiaries! + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} diff --git a/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/TestInfrastructureConfig.java b/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/TestInfrastructureConfig.java new file mode 100644 index 0000000..bd29e01 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/TestInfrastructureConfig.java @@ -0,0 +1,28 @@ +package rewards; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import config.RewardsConfig; + +@Configuration +@Import(RewardsConfig.class) +public class TestInfrastructureConfig { + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ + @Bean + public DataSource dataSource(){ + return + (new EmbeddedDatabaseBuilder()) + .addScript("classpath:rewards/testdb/schema.sql") + .addScript("classpath:rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..98b7353 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,72 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/internal/StubAccountRepository.java b/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..b926be2 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,43 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new EmptyResultDataAccessException(1); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..ce1f820 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,38 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/internal/StubRewardRepository.java b/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..2487aca --- /dev/null +++ b/lab/12-javaconfig-dependency-injection-solution/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import java.util.Random; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/build.gradle b/lab/12-javaconfig-dependency-injection/build.gradle new file mode 100644 index 0000000..4d6c184 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/build.gradle @@ -0,0 +1,7 @@ +/* + * This file was generated by the Gradle 'init' task. + */ + +dependencies { + implementation project(':00-rewards-common') +} diff --git a/lab/12-javaconfig-dependency-injection/pom.xml b/lab/12-javaconfig-dependency-injection/pom.xml new file mode 100644 index 0000000..b0e372c --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + 12-javaconfig-dependency-injection + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/config/RewardsConfig.java b/lab/12-javaconfig-dependency-injection/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..4d72577 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/config/RewardsConfig.java @@ -0,0 +1,50 @@ +package config; + +import javax.sql.DataSource; + +/** + * TODO-00: In this lab, you are going to exercise the following: + * - Creating Spring configuration class + * - Defining bean definitions within the configuration class + * - Specifying the dependency relationships among beans + * - Injecting dependencies through constructor injection + * - Creating Spring application context in the test code + * (WITHOUT using Spring testContext framework) + * + * TODO-01: Make this class a Spring configuration class + * - Use an appropriate annotation. + * + * TODO-02: Define four empty @Bean methods, one for the + * reward-network and three for the repositories. + * - The names of the beans should be: + * - rewardNetwork + * - accountRepository + * - restaurantRepository + * - rewardRepository + * + * TODO-03: Inject DataSource through constructor injection + * - Each repository implementation has a DataSource + * property to be set, but the DataSource is defined + * elsewhere (TestInfrastructureConfig.java), so you + * will need to define a constructor for this class + * that accepts a DataSource parameter. + * - As it is the only constructor, @Autowired is optional. + * + * TODO-04: Implement each @Bean method to contain the code + * needed to instantiate its object and set its + * dependencies + * - You can create beans from the following implementation classes + * - rewardNetwork bean from RewardNetworkImpl class + * - accountRepository bean from JdbcAccountRepository class + * - restaurantRepository bean from JdbcRestaurantRepository class + * - rewardRepository bean from JdbcRewardRepository class + * - Note that return type of each bean method should be an interface + * not an implementation. + */ + +public class RewardsConfig { + + // Set this by adding a constructor. + private DataSource dataSource; + +} diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/config/package.html b/lab/12-javaconfig-dependency-injection/src/main/java/config/package.html new file mode 100644 index 0000000..a2ab399 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/config/package.html @@ -0,0 +1,7 @@ + + +

+Configuration classes for the RewardNetwork. +

+ + diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/AccountContribution.java b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/Dining.java b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..4bc4907 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a restaurant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a restaurant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a restaurant on the date + * specified. A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/RewardConfirmation.java b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/RewardNetwork.java b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..cb3191e --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,52 @@ +package rewards.internal; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/Account.java b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..7b48fa3 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,141 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. + *

+ * Callers should not attempt to hold on or modify the returned set. This method should only be used transitively; + * for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/AccountRepository.java b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..16c6079 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/Beneficiary.java b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/JdbcAccountRepository.java b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/JdbcAccountRepository.java new file mode 100644 index 0000000..5181837 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/JdbcAccountRepository.java @@ -0,0 +1,139 @@ +package rewards.internal.account; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.springframework.dao.EmptyResultDataAccessException; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Loads accounts from a data source using the JDBC API. + */ +public class JdbcAccountRepository implements AccountRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to load accounts. + * @param dataSource the data source + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Account findByCreditCard(String creditCardNumber) { + + String sql = """ + select a.ID as ID, a.NUMBER as ACCOUNT_NUMBER, a.NAME as ACCOUNT_NAME, c.NUMBER as CREDIT_CARD_NUMBER, \ + b.NAME as BENEFICIARY_NAME, b.ALLOCATION_PERCENTAGE as BENEFICIARY_ALLOCATION_PERCENTAGE, b.SAVINGS as BENEFICIARY_SAVINGS \ + from T_ACCOUNT a, T_ACCOUNT_CREDIT_CARD c \ + left outer join T_ACCOUNT_BENEFICIARY b \ + on a.ID = b.ACCOUNT_ID \ + where c.ACCOUNT_ID = a.ID and c.NUMBER = ?\ + """; + + Account account = null; + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + try { + conn = dataSource.getConnection(); + ps = conn.prepareStatement(sql); + ps.setString(1, creditCardNumber); + rs = ps.executeQuery(); + account = mapAccount(rs); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred finding by credit card number", e); + } finally { + if (rs != null) { + try { + // Close to prevent database cursor exhaustion + rs.close(); + } catch (SQLException ex) { + } + } + if (ps != null) { + try { + // Close to prevent database cursor exhaustion + ps.close(); + } catch (SQLException ex) { + } + } + if (conn != null) { + try { + // Close to prevent database connection exhaustion + conn.close(); + } catch (SQLException ex) { + } + } + } + return account; + } + + public void updateBeneficiaries(Account account) { + String sql = "update T_ACCOUNT_BENEFICIARY SET SAVINGS = ? where ACCOUNT_ID = ? and NAME = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + for (Beneficiary beneficiary : account.getBeneficiaries()) { + ps.setBigDecimal(1, beneficiary.getSavings().asBigDecimal()); + ps.setLong(2, account.getEntityId()); + ps.setString(3, beneficiary.getName()); + ps.executeUpdate(); + } + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred updating beneficiary savings", e); + } + } + + /** + * Map the rows returned from the join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY to a fully-reconstituted Account + * aggregate. + * @param rs the set of rows returned from the query + * @return the mapped Account aggregate + * @throws SQLException an exception occurred extracting data from the result set + */ + private Account mapAccount(ResultSet rs) throws SQLException { + Account account = null; + while (rs.next()) { + if (account == null) { + String number = rs.getString("ACCOUNT_NUMBER"); + String name = rs.getString("ACCOUNT_NAME"); + account = new Account(number, name); + // set internal entity identifier (primary key) + account.setEntityId(rs.getLong("ID")); + } + Beneficiary b = mapBeneficiary(rs); + if (b != null) { + account.restoreBeneficiary(b); + } + } + if (account == null) { + // no rows returned - throw an empty result exception + throw new EmptyResultDataAccessException(1); + } + return account; + } + + /** + * Maps the beneficiary columns in a single row to an AllocatedBeneficiary object. + * @param rs the result set with its cursor positioned at the current row + * @return an allocated beneficiary + * @throws SQLException an exception occurred extracting data from the result set + */ + private Beneficiary mapBeneficiary(ResultSet rs) throws SQLException { + String name = rs.getString("BENEFICIARY_NAME"); + if (name == null) { + // apparently no beneficiary for this + return null; + } + MonetaryAmount savings = MonetaryAmount.valueOf(rs.getString("BENEFICIARY_SAVINGS")); + Percentage allocationPercentage = Percentage.valueOf(rs.getString("BENEFICIARY_ALLOCATION_PERCENTAGE")); + return new Beneficiary(name, allocationPercentage, savings); + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/package.html b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/package.html b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java new file mode 100644 index 0000000..6e65815 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java @@ -0,0 +1,97 @@ +package rewards.internal.restaurant; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.springframework.dao.EmptyResultDataAccessException; + +import common.money.Percentage; + +/** + * Loads restaurants from a data source using the JDBC API. + */ +public class JdbcRestaurantRepository implements RestaurantRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to load restaurants. + * @param dataSource the data source + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + String sql = "select MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE from T_RESTAURANT where MERCHANT_NUMBER = ?"; + Restaurant restaurant = null; + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + try { + conn = dataSource.getConnection(); + ps = conn.prepareStatement(sql); + ps.setString(1, merchantNumber); + rs = ps.executeQuery(); + advanceToNextRow(rs); + restaurant = mapRestaurant(rs); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred finding by merchant number", e); + } finally { + if (rs != null) { + try { + // Close to prevent database cursor exhaustion + rs.close(); + } catch (SQLException ex) { + } + } + if (ps != null) { + try { + // Close to prevent database cursor exhaustion + ps.close(); + } catch (SQLException ex) { + } + } + if (conn != null) { + try { + // Close to prevent database connection exhaustion + conn.close(); + } catch (SQLException ex) { + } + } + } + return restaurant; + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + * @param rs the result set with its cursor positioned at the current row + */ + private Restaurant mapRestaurant(ResultSet rs) throws SQLException { + // get the row column data + String name = rs.getString("NAME"); + String number = rs.getString("MERCHANT_NUMBER"); + Percentage benefitPercentage = Percentage.valueOf(rs.getString("BENEFIT_PERCENTAGE")); + // map to the object + Restaurant restaurant = new Restaurant(number, name); + restaurant.setBenefitPercentage(benefitPercentage); + return restaurant; + } + + /** + * Advances a ResultSet to the next row and throws an exception if there are no rows. + * @param rs the ResultSet to advance + * @throws EmptyResultDataAccessException if there is no next row + * @throws SQLException + */ + private void advanceToNextRow(ResultSet rs) throws EmptyResultDataAccessException, SQLException { + if (!rs.next()) { + throw new EmptyResultDataAccessException(1); + } + } + +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..aa642ae --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,79 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on a benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + return dining.getAmount().multiplyBy(benefitPercentage); + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage; + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/restaurant/package.html b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..15554d6 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,57 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import javax.sql.DataSource; +import java.sql.*; + +/** + * JDBC implementation of a reward repository that records the result of a reward transaction by inserting a reward + * confirmation record. + */ +public class JdbcRewardRepository implements RewardRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to insert rewards. + * @param dataSource the data source + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + String confirmationNumber = nextConfirmationNumber(); + ps.setString(1, confirmationNumber); + ps.setBigDecimal(2, contribution.getAmount().asBigDecimal()); + ps.setDate(3, new Date(SimpleDate.today().inMilliseconds())); + ps.setString(4, contribution.getAccountNumber()); + ps.setString(5, dining.getMerchantNumber()); + ps.setDate(6, new Date(dining.getDate().inMilliseconds())); + ps.setBigDecimal(7, dining.getAmount().asBigDecimal()); + ps.execute(); + return new RewardConfirmation(confirmationNumber, contribution); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred inserting reward record", e); + } + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getString(1); + } catch (SQLException e) { + throw new RuntimeException("SQL exception getting next confirmation number", e); + } + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/reward/package.html b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/12-javaconfig-dependency-injection/src/main/java/rewards/package.html b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/12-javaconfig-dependency-injection/src/test/java/config/RewardsConfigTests.java b/lab/12-javaconfig-dependency-injection/src/test/java/config/RewardsConfigTests.java new file mode 100644 index 0000000..31169d3 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/test/java/config/RewardsConfigTests.java @@ -0,0 +1,69 @@ +package config; + +import org.assertj.core.api.Fail; +import org.mockito.Mockito; + +import javax.sql.DataSource; +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Unit test the Spring configuration class to ensure it is creating the right + * beans. + */ +@SuppressWarnings("unused") +public class RewardsConfigTests { + // Provide a mock object for testing + private final DataSource dataSource = Mockito.mock(DataSource.class); + + // TODO-05: Run the test + // - Uncomment the code below between /* and */ + // - If you have implemented RewardsConfig as requested it should compile. + // - Fix RewardsConfig if necessary. + // - Now run the test, it should pass. + + /* + private RewardsConfig rewardsConfig = new RewardsConfig(dataSource); + + @Test + public void getBeans() { + RewardNetwork rewardNetwork = rewardsConfig.rewardNetwork(); + assertTrue(rewardNetwork instanceof RewardNetworkImpl); + + AccountRepository accountRepository = rewardsConfig.accountRepository(); + assertTrue(accountRepository instanceof JdbcAccountRepository); + checkDataSource(accountRepository); + + RestaurantRepository restaurantRepository = rewardsConfig.restaurantRepository(); + assertTrue(restaurantRepository instanceof JdbcRestaurantRepository); + checkDataSource(restaurantRepository); + + RewardRepository rewardsRepository = rewardsConfig.rewardRepository(); + assertTrue(rewardsRepository instanceof JdbcRewardRepository); + checkDataSource(rewardsRepository); + } + */ + + /** + * Ensure the data-source is set for the repository. Uses reflection as we do + * not wish to provide a getDataSource() method. + * + * @param repository One of our three repositories. + * + */ + private void checkDataSource(Object repository) { + Class repositoryClass = repository.getClass(); + + try { + Field dataSource = repositoryClass.getDeclaredField("dataSource"); + dataSource.setAccessible(true); + assertNotNull(dataSource.get(repository)); + } catch (Exception e) { + String failureMessage = "Unable to validate dataSource in " + repositoryClass.getSimpleName(); + System.out.println(failureMessage); + e.printStackTrace(); + Fail.fail(failureMessage); + } + } +} diff --git a/lab/12-javaconfig-dependency-injection/src/test/java/rewards/TestInfrastructureConfig.java b/lab/12-javaconfig-dependency-injection/src/test/java/rewards/TestInfrastructureConfig.java new file mode 100644 index 0000000..90f3b4b --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/test/java/rewards/TestInfrastructureConfig.java @@ -0,0 +1,69 @@ +package rewards; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +/** + * TODO-06: Study this configuration class used for testing + * - It contains a @Bean method that returns DataSource. + * - It also creates and populates in-memory HSQL database tables + * using two SQL scripts. + * - Note that the two scripts are located under the + * 'src/main/resources/rewards/testdb' directory of + * the '00-rewards-common' project + * - Do not modify this method. + * + * TODO-07: Import your application configuration file (RewardsConfig) + * - Now the test code should have access to all the beans defined in + * the RewardsConfig configuration class + * + * TODO-08: Create a new JUnit 5 test class + * - Call it RewardNetworkTests + * - Create it in the same package this configuration class is located. + * - Ask for a setUp() method to be generated within your IDE. + * + * NOTE: The appendices at the bottom of the course Home Page includes + * a section on creating JUnit tests in an IDE. + * + * TODO-09: Make sure the setUp() method in the RewardNetworkTests class is annotated with @BeforeEach. + * - In the setUp() method, create an application context using + * this configuration class - use run(..) static method of + * the SpringApplication class + * - Then get the 'rewardNetwork' bean from the application context + * and assign it to a private field for use later. + * + * TODO-10: We can test the setup by running an empty test. + * - If your IDE automatically generated a @Test method, rename it + * testRewardForDining. Delete any code in the method body. + * - Otherwise add a testRewardForDining method & annotate it with + * @Test (make sure the @Test is from org.junit.jupiter.api.Test ). + * - Run the test. If your setup() is working, you get a green bar. + * + * TODO-11: Finally run a real test. + * - Copy the unit test (the @Test method) from + * RewardNetworkImplTests#testRewardForDining() under + * rewards.internal test package - we are testing + * the same code, but using a different setup. + * - Run the test - it should pass if you have configured everything + * correctly. Congratulations, you are done. + * - If your test fails - did you miss the import in TO DO 7 above? + * + */ +@Configuration +public class TestInfrastructureConfig { + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ + @Bean + public DataSource dataSource() { + return (new EmbeddedDatabaseBuilder()) // + .addScript("classpath:rewards/testdb/schema.sql") // + .addScript("classpath:rewards/testdb/data.sql") // + .build(); + } +} diff --git a/lab/12-javaconfig-dependency-injection/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/12-javaconfig-dependency-injection/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..507e154 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,73 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ + +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/test/java/rewards/internal/StubAccountRepository.java b/lab/12-javaconfig-dependency-injection/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..b926be2 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,43 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new EmptyResultDataAccessException(1); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/12-javaconfig-dependency-injection/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..ce1f820 --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,38 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/test/java/rewards/internal/StubRewardRepository.java b/lab/12-javaconfig-dependency-injection/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..2487aca --- /dev/null +++ b/lab/12-javaconfig-dependency-injection/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import java.util.Random; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/12-javaconfig-dependency-injection/src/test/resources/scripts-in-rewards-common.txt b/lab/12-javaconfig-dependency-injection/src/test/resources/scripts-in-rewards-common.txt new file mode 100644 index 0000000..e69de29 diff --git a/lab/16-annotations-solution/build.gradle b/lab/16-annotations-solution/build.gradle new file mode 100644 index 0000000..4d6c184 --- /dev/null +++ b/lab/16-annotations-solution/build.gradle @@ -0,0 +1,7 @@ +/* + * This file was generated by the Gradle 'init' task. + */ + +dependencies { + implementation project(':00-rewards-common') +} diff --git a/lab/16-annotations-solution/pom.xml b/lab/16-annotations-solution/pom.xml new file mode 100644 index 0000000..a0b50e6 --- /dev/null +++ b/lab/16-annotations-solution/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + 16-annotations-solution + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + diff --git a/lab/16-annotations-solution/src/main/java/config/RewardsConfig.java b/lab/16-annotations-solution/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..c51dd3a --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/config/RewardsConfig.java @@ -0,0 +1,10 @@ +package config; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan("rewards.internal") +public class RewardsConfig { + +} diff --git a/lab/16-annotations-solution/src/main/java/rewards/AccountContribution.java b/lab/16-annotations-solution/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/main/java/rewards/Dining.java b/lab/16-annotations-solution/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/main/java/rewards/RewardConfirmation.java b/lab/16-annotations-solution/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/main/java/rewards/RewardNetwork.java b/lab/16-annotations-solution/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/16-annotations-solution/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..d07b40a --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,55 @@ +package rewards.internal; + +import org.springframework.stereotype.Service; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +@Service("rewardNetwork") +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/main/java/rewards/internal/account/Account.java b/lab/16-annotations-solution/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..7b48fa3 --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,141 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. + *

+ * Callers should not attempt to hold on or modify the returned set. This method should only be used transitively; + * for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/main/java/rewards/internal/account/AccountRepository.java b/lab/16-annotations-solution/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..16c6079 --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/main/java/rewards/internal/account/Beneficiary.java b/lab/16-annotations-solution/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java b/lab/16-annotations-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java new file mode 100644 index 0000000..e3b9821 --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java @@ -0,0 +1,129 @@ +package rewards.internal.account; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.stereotype.Repository; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Loads accounts from a data source using the JDBC API. + */ +@Repository("accountRepository") +public class JdbcAccountRepository implements AccountRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to load accounts. + * @param dataSource the data source + */ + @Autowired + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Account findByCreditCard(String creditCardNumber) { + String sql = "select a.ID as ID, a.NUMBER as ACCOUNT_NUMBER, a.NAME as ACCOUNT_NAME, c.NUMBER as CREDIT_CARD_NUMBER, b.NAME as BENEFICIARY_NAME, b.ALLOCATION_PERCENTAGE as BENEFICIARY_ALLOCATION_PERCENTAGE, b.SAVINGS as BENEFICIARY_SAVINGS from T_ACCOUNT a, T_ACCOUNT_BENEFICIARY b, T_ACCOUNT_CREDIT_CARD c where ID = b.ACCOUNT_ID and ID = c.ACCOUNT_ID and c.NUMBER = ?"; + Account account = null; + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + try { + conn = dataSource.getConnection(); + ps = conn.prepareStatement(sql); + ps.setString(1, creditCardNumber); + rs = ps.executeQuery(); + account = mapAccount(rs); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred finding by credit card number", e); + } finally { + if (rs != null) { + try { + // Close to prevent database cursor exhaustion + rs.close(); + } catch (SQLException ex) { + } + } + if (ps != null) { + try { + // Close to prevent database cursor exhaustion + ps.close(); + } catch (SQLException ex) { + } + } + if (conn != null) { + try { + // Close to prevent database connection exhaustion + conn.close(); + } catch (SQLException ex) { + } + } + } + return account; + } + + public void updateBeneficiaries(Account account) { + String sql = "update T_ACCOUNT_BENEFICIARY SET SAVINGS = ? where ACCOUNT_ID = ? and NAME = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + for (Beneficiary beneficiary : account.getBeneficiaries()) { + ps.setBigDecimal(1, beneficiary.getSavings().asBigDecimal()); + ps.setLong(2, account.getEntityId()); + ps.setString(3, beneficiary.getName()); + ps.executeUpdate(); + } + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred updating beneficiary savings", e); + } + // Close to prevent database cursor exhaustion + // Close to prevent database connection exhaustion + } + + /** + * Map the rows returned from the join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY to a fully-reconstituted Account + * aggregate. + * @param rs the set of rows returned from the query + * @return the mapped Account aggregate + * @throws SQLException an exception occurred extracting data from the result set + */ + private Account mapAccount(ResultSet rs) throws SQLException { + Account account = null; + while (rs.next()) { + if (account == null) { + String number = rs.getString("ACCOUNT_NUMBER"); + String name = rs.getString("ACCOUNT_NAME"); + account = new Account(number, name); + // set internal entity identifier (primary key) + account.setEntityId(rs.getLong("ID")); + } + account.restoreBeneficiary(mapBeneficiary(rs)); + } + if (account == null) { + // no rows returned - throw an empty result exception + throw new EmptyResultDataAccessException(1); + } + return account; + } + + /** + * Maps the beneficiary columns in a single row to an AllocatedBeneficiary object. + * @param rs the result set with its cursor positioned at the current row + * @return an allocated beneficiary + * @throws SQLException an exception occurred extracting data from the result set + */ + private Beneficiary mapBeneficiary(ResultSet rs) throws SQLException { + String name = rs.getString("BENEFICIARY_NAME"); + MonetaryAmount savings = MonetaryAmount.valueOf(rs.getString("BENEFICIARY_SAVINGS")); + Percentage allocationPercentage = Percentage.valueOf(rs.getString("BENEFICIARY_ALLOCATION_PERCENTAGE")); + return new Beneficiary(name, allocationPercentage, savings); + } +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/main/java/rewards/internal/account/package.html b/lab/16-annotations-solution/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/16-annotations-solution/src/main/java/rewards/internal/package.html b/lab/16-annotations-solution/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/16-annotations-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java b/lab/16-annotations-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java new file mode 100644 index 0000000..254f6a8 --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java @@ -0,0 +1,118 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.stereotype.Repository; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.Map; + +/** + * Loads restaurants from a data source using the JDBC API. + * + * This implementation caches restaurants to improve performance. + * The cache is populated on initialization and cleared on destruction. + */ +@Repository("restaurantRepository") +public class JdbcRestaurantRepository implements RestaurantRepository { + + private DataSource dataSource; + + /** + * The Restaurant object cache. Cached restaurants are indexed by their merchant numbers. + */ + private Map restaurantCache; + + /** + * The constructor sets the data source this repository will use to load restaurants. + * When the instance of JdbcRestaurantRepository is created, a Restaurant cache is + * populated for read only access + * + * @param dataSource the data source + */ + + public JdbcRestaurantRepository(DataSource dataSource){ + this.dataSource = dataSource; + this.populateRestaurantCache(); + } + + public JdbcRestaurantRepository(){} + + @Autowired + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + + public Restaurant findByMerchantNumber(String merchantNumber) { + return queryRestaurantCache(merchantNumber); + } + + /** + * Helper method that populates the {@link #restaurantCache restaurant object cache} from rows in the T_RESTAURANT + * table. Cached restaurants are indexed by their merchant numbers. This method is called on initialization. + */ + @PostConstruct + void populateRestaurantCache() { + restaurantCache = new HashMap<>(); + String sql = "select MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE from T_RESTAURANT"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + Restaurant restaurant = mapRestaurant(rs); + // index the restaurant by its merchant number + restaurantCache.put(restaurant.getNumber(), restaurant); + } + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred finding by merchant number", e); + } + } + + /** + * Helper method that simply queries the cache of restaurants. + * + * @param merchantNumber the restaurant's merchant number + * @return the restaurant + * @throws EmptyResultDataAccessException if no restaurant was found with that merchant number + */ + private Restaurant queryRestaurantCache(String merchantNumber) { + Restaurant restaurant = restaurantCache.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } + + /** + * Helper method that clears the cache of restaurants. This method is called on destruction + */ + @PreDestroy + void clearRestaurantCache() { + restaurantCache.clear(); + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + * + * @param rs the result set with its cursor positioned at the current row + */ + private Restaurant mapRestaurant(ResultSet rs) throws SQLException { + // get the row column data + String name = rs.getString("NAME"); + String number = rs.getString("MERCHANT_NUMBER"); + Percentage benefitPercentage = Percentage.valueOf(rs.getString("BENEFIT_PERCENTAGE")); + // map to the object + Restaurant restaurant = new Restaurant(number, name); + restaurant.setBenefitPercentage(benefitPercentage); + return restaurant; + } +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/16-annotations-solution/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..aa642ae --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,79 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on a benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + return dining.getAmount().multiplyBy(benefitPercentage); + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage; + } +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/16-annotations-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/16-annotations-solution/src/main/java/rewards/internal/restaurant/package.html b/lab/16-annotations-solution/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/16-annotations-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/16-annotations-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..fddb461 --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,61 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import javax.sql.DataSource; +import java.sql.*; + +/** + * JDBC implementation of a reward repository that records the result of a reward transaction by inserting a reward + * confirmation record. + */ +@Repository("rewardRepository") +public class JdbcRewardRepository implements RewardRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to insert rewards. + * @param dataSource the data source + */ + @Autowired + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + String confirmationNumber = nextConfirmationNumber(); + ps.setString(1, confirmationNumber); + ps.setBigDecimal(2, contribution.getAmount().asBigDecimal()); + ps.setDate(3, new Date(SimpleDate.today().inMilliseconds())); + ps.setString(4, contribution.getAccountNumber()); + ps.setString(5, dining.getMerchantNumber()); + ps.setDate(6, new Date(dining.getDate().inMilliseconds())); + ps.setBigDecimal(7, dining.getAmount().asBigDecimal()); + ps.execute(); + return new RewardConfirmation(confirmationNumber, contribution); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred inserting reward record", e); + } + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getString(1); + } catch (SQLException e) { + throw new RuntimeException("SQL exception getting next confirmation number", e); + } + } +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/16-annotations-solution/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/main/java/rewards/internal/reward/package.html b/lab/16-annotations-solution/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/16-annotations-solution/src/main/java/rewards/package.html b/lab/16-annotations-solution/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/16-annotations-solution/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/16-annotations-solution/src/test/java/rewards/RewardNetworkTests.java b/lab/16-annotations-solution/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..8fa51ce --- /dev/null +++ b/lab/16-annotations-solution/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,67 @@ +package rewards; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.context.ApplicationContext; + +import common.money.MonetaryAmount; + +/** + * A system test that verifies the components of the RewardNetwork application work together to reward for dining + * successfully. Uses Spring to bootstrap the application for use in a test environment. + */ +public class RewardNetworkTests { + + /** + * The object being tested. + */ + private RewardNetwork rewardNetwork; + + + @BeforeEach + public void setUp() { + // Create the test configuration for the application from two classes: + ApplicationContext context = SpringApplication.run(TestInfrastructureConfig.class); + + // Get the bean to use to invoke the application + rewardNetwork = context.getBean(RewardNetwork.class); + + } + + + + @Test + public void rewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} diff --git a/lab/16-annotations-solution/src/test/java/rewards/TestInfrastructureConfig.java b/lab/16-annotations-solution/src/test/java/rewards/TestInfrastructureConfig.java new file mode 100644 index 0000000..bd29e01 --- /dev/null +++ b/lab/16-annotations-solution/src/test/java/rewards/TestInfrastructureConfig.java @@ -0,0 +1,28 @@ +package rewards; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import config.RewardsConfig; + +@Configuration +@Import(RewardsConfig.class) +public class TestInfrastructureConfig { + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ + @Bean + public DataSource dataSource(){ + return + (new EmbeddedDatabaseBuilder()) + .addScript("classpath:rewards/testdb/schema.sql") + .addScript("classpath:rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/16-annotations-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/16-annotations-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..98b7353 --- /dev/null +++ b/lab/16-annotations-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,72 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/test/java/rewards/internal/StubAccountRepository.java b/lab/16-annotations-solution/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..b926be2 --- /dev/null +++ b/lab/16-annotations-solution/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,43 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new EmptyResultDataAccessException(1); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/16-annotations-solution/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..ce1f820 --- /dev/null +++ b/lab/16-annotations-solution/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,38 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/test/java/rewards/internal/StubRewardRepository.java b/lab/16-annotations-solution/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..2487aca --- /dev/null +++ b/lab/16-annotations-solution/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import java.util.Random; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/16-annotations-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java b/lab/16-annotations-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java new file mode 100644 index 0000000..b14fdce --- /dev/null +++ b/lab/16-annotations-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java @@ -0,0 +1,76 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC restaurant repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRestaurantRepositoryTests { + + private JdbcRestaurantRepository repository; + + @BeforeEach + public void setUp() { + // simulate the Spring bean initialization lifecycle: + + // first, construct the bean + repository = new JdbcRestaurantRepository(); + + // then, inject its dependencies + repository.setDataSource(createTestDataSource()); + + // lastly, initialize the bean + repository.populateRestaurantCache(); + } + + @AfterEach + public void tearDown() { + // simulate the Spring bean destruction lifecycle: + + // destroy the bean + repository.clearRestaurantCache(); + } + + @Test + public void findRestaurantByMerchantNumber() { + Restaurant restaurant = repository.findByMerchantNumber("1234567890"); + assertNotNull(restaurant, "restaurant is null - repository cache not likely initialized"); + assertEquals("1234567890", restaurant.getNumber(), "number is wrong"); + assertEquals("AppleBees", restaurant.getName(), "name is wrong"); + assertEquals(Percentage.valueOf("8%"), restaurant.getBenefitPercentage(), "benefitPercentage is wrong"); + } + + @Test + public void findRestaurantByBogusMerchantNumber() { + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("bogus"); + }); + } + + @Test + public void restaurantCacheClearedAfterDestroy() { + // force early tear down + tearDown(); + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("1234567890"); + }); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/16-annotations/build.gradle b/lab/16-annotations/build.gradle new file mode 100644 index 0000000..4d6c184 --- /dev/null +++ b/lab/16-annotations/build.gradle @@ -0,0 +1,7 @@ +/* + * This file was generated by the Gradle 'init' task. + */ + +dependencies { + implementation project(':00-rewards-common') +} diff --git a/lab/16-annotations/pom.xml b/lab/16-annotations/pom.xml new file mode 100644 index 0000000..cd95033 --- /dev/null +++ b/lab/16-annotations/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + 16-annotations + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + diff --git a/lab/16-annotations/src/main/java/config/RewardsConfig.java b/lab/16-annotations/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..0f30b0f --- /dev/null +++ b/lab/16-annotations/src/main/java/config/RewardsConfig.java @@ -0,0 +1,63 @@ +package config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import rewards.RewardNetwork; +import rewards.internal.RewardNetworkImpl; +import rewards.internal.account.AccountRepository; +import rewards.internal.account.JdbcAccountRepository; +import rewards.internal.restaurant.JdbcRestaurantRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.JdbcRewardRepository; +import rewards.internal.reward.RewardRepository; + +import javax.sql.DataSource; + +/** + * TODO-07: Perform component-scanning and run the test again + * - Add an appropriate annotation to this class to cause component scanning. + * - Set the base package to pick up all the classes we have annotated so far. + * - Save all changes, Re-run the RewardNetworkTests. It should now pass. + */ +@Configuration +public class RewardsConfig { + + final DataSource dataSource; + + public RewardsConfig(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Bean + public RewardNetwork rewardNetwork(){ + return new RewardNetworkImpl( + accountRepository(), + restaurantRepository(), + rewardRepository()); + } + + @Bean + public AccountRepository accountRepository(){ + JdbcAccountRepository repository = new JdbcAccountRepository(); + repository.setDataSource(dataSource); + return repository; + } + + @Bean + public RestaurantRepository restaurantRepository(){ + JdbcRestaurantRepository repository = new JdbcRestaurantRepository(dataSource); + return repository; + } + + @Bean + public RewardRepository rewardRepository(){ + JdbcRewardRepository repository = new JdbcRewardRepository(); + repository.setDataSource(dataSource); + return repository; + } + + // TODO-02: Remove all of the @Bean methods above. + // - Remove the code that autowires DataSource as well. + // - Run the RewardNetworkTests test. It should fail. Why? + +} diff --git a/lab/16-annotations/src/main/java/rewards/AccountContribution.java b/lab/16-annotations/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/16-annotations/src/main/java/rewards/Dining.java b/lab/16-annotations/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/16-annotations/src/main/java/rewards/RewardConfirmation.java b/lab/16-annotations/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/16-annotations/src/main/java/rewards/RewardNetwork.java b/lab/16-annotations/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/16-annotations/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/16-annotations/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..d8e5442 --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,64 @@ +package rewards.internal; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This class is an + * application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits + * to accounts for dining. + * + * Said in other words, this class implements the "reward account + * for dining" use case. + */ + +/* TODO-03: Let this class to be found in component-scanning + * - Annotate this class with an appropriate stereotype annotation + * to cause component-scanning to create a Spring bean from this class. + * - Inject all 3 dependencies. Decide if you should use field + * injection or constructor injection. + */ + +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/16-annotations/src/main/java/rewards/internal/account/Account.java b/lab/16-annotations/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..7b48fa3 --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,141 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. + *

+ * Callers should not attempt to hold on or modify the returned set. This method should only be used transitively; + * for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/16-annotations/src/main/java/rewards/internal/account/AccountRepository.java b/lab/16-annotations/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..16c6079 --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/16-annotations/src/main/java/rewards/internal/account/Beneficiary.java b/lab/16-annotations/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/16-annotations/src/main/java/rewards/internal/account/JdbcAccountRepository.java b/lab/16-annotations/src/main/java/rewards/internal/account/JdbcAccountRepository.java new file mode 100644 index 0000000..2a2396c --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/internal/account/JdbcAccountRepository.java @@ -0,0 +1,133 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.springframework.dao.EmptyResultDataAccessException; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Loads accounts from a data source using the JDBC API. + */ + +/* TODO-05: Let this class to be found in component-scanning + * - Annotate the class with an appropriate stereotype annotation + * to cause component-scan to detect and load this bean. + * - Inject dataSource by annotating setDataSource() method + * with @Autowired. + */ + +public class JdbcAccountRepository implements AccountRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to load accounts. + * + * @param dataSource the data source + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Account findByCreditCard(String creditCardNumber) { + String sql = "select a.ID as ID, a.NUMBER as ACCOUNT_NUMBER, a.NAME as ACCOUNT_NAME, c.NUMBER as CREDIT_CARD_NUMBER, b.NAME as BENEFICIARY_NAME, b.ALLOCATION_PERCENTAGE as BENEFICIARY_ALLOCATION_PERCENTAGE, b.SAVINGS as BENEFICIARY_SAVINGS from T_ACCOUNT a, T_ACCOUNT_BENEFICIARY b, T_ACCOUNT_CREDIT_CARD c where ID = b.ACCOUNT_ID and ID = c.ACCOUNT_ID and c.NUMBER = ?"; + Account account = null; + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + try { + conn = dataSource.getConnection(); + ps = conn.prepareStatement(sql); + ps.setString(1, creditCardNumber); + rs = ps.executeQuery(); + account = mapAccount(rs); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred finding by credit card number", e); + } finally { + if (rs != null) { + try { + // Close to prevent database cursor exhaustion + rs.close(); + } catch (SQLException ex) { + } + } + if (ps != null) { + try { + // Close to prevent database cursor exhaustion + ps.close(); + } catch (SQLException ex) { + } + } + if (conn != null) { + try { + // Close to prevent database connection exhaustion + conn.close(); + } catch (SQLException ex) { + } + } + } + return account; + } + + public void updateBeneficiaries(Account account) { + String sql = "update T_ACCOUNT_BENEFICIARY SET SAVINGS = ? where ACCOUNT_ID = ? and NAME = ?"; + try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + for (Beneficiary beneficiary : account.getBeneficiaries()) { + ps.setBigDecimal(1, beneficiary.getSavings().asBigDecimal()); + ps.setLong(2, account.getEntityId()); + ps.setString(3, beneficiary.getName()); + ps.executeUpdate(); + } + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred updating beneficiary savings", e); + } + // Close to prevent database cursor exhaustion + // Close to prevent database connection exhaustion + } + + /** + * Map the rows returned from the join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY + * to a fully-reconstituted Account aggregate. + * + * @param rs the set of rows returned from the query + * @return the mapped Account aggregate + * @throws SQLException an exception occurred extracting data from the result set + */ + private Account mapAccount(ResultSet rs) throws SQLException { + Account account = null; + while (rs.next()) { + if (account == null) { + String number = rs.getString("ACCOUNT_NUMBER"); + String name = rs.getString("ACCOUNT_NAME"); + account = new Account(number, name); + // set internal entity identifier (primary key) + account.setEntityId(rs.getLong("ID")); + } + account.restoreBeneficiary(mapBeneficiary(rs)); + } + if (account == null) { + // no rows returned - throw an empty result exception + throw new EmptyResultDataAccessException(1); + } + return account; + } + + /** + * Maps the beneficiary columns in a single row to an AllocatedBeneficiary object. + * + * @param rs the result set with its cursor positioned at the current row + * @return an allocated beneficiary + * @throws SQLException an exception occurred extracting data from the result set + */ + private Beneficiary mapBeneficiary(ResultSet rs) throws SQLException { + String name = rs.getString("BENEFICIARY_NAME"); + MonetaryAmount savings = MonetaryAmount.valueOf(rs.getString("BENEFICIARY_SAVINGS")); + Percentage allocationPercentage = Percentage.valueOf(rs.getString("BENEFICIARY_ALLOCATION_PERCENTAGE")); + return new Beneficiary(name, allocationPercentage, savings); + } +} diff --git a/lab/16-annotations/src/main/java/rewards/internal/account/package.html b/lab/16-annotations/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/16-annotations/src/main/java/rewards/internal/package.html b/lab/16-annotations/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/16-annotations/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java b/lab/16-annotations/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java new file mode 100644 index 0000000..11435f7 --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java @@ -0,0 +1,163 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.springframework.dao.EmptyResultDataAccessException; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.Map; + +/** + * Loads restaurants from a data source using the JDBC API. + * + * This implementation should cache restaurants to improve performance. The + * cache should be populated on initialization and cleared on destruction. + */ + +/* TODO-06: Let this class to be found in component-scanning + * - Annotate the class with an appropriate stereotype annotation + * to cause component-scanning to detect and load this bean. + * - Inject dataSource. Use constructor injection in this case. + * Note that there are already two constructors, one of which + * is no-arg constructor. + */ + +/* + * TODO-08: Use Setter injection for DataSource + * - Change the configuration to set the dataSource + * property using setDataSource(). + * + * To do this, you must MOVE the @Autowired annotation + * you might have set in the previous step on the + * constructor injecting DataSource. + * So neither constructor should be annotated with + * @Autowired now, so Spring uses + * the default constructor by default. + * + * - Re-run the test. It should fail. + * - Examine the stack trace and see if you can + * understand why. (If not, refer to lab document). + * We will fix this error in the next step. + */ + +public class JdbcRestaurantRepository implements RestaurantRepository { + + private DataSource dataSource; + + /** + * The Restaurant object cache. Cached restaurants are indexed + * by their merchant numbers. + */ + private Map restaurantCache; + + /** + * The constructor sets the data source this repository will use to load + * restaurants. When the instance of JdbcRestaurantRepository is created, a + * Restaurant cache is populated for read only access + */ + + public JdbcRestaurantRepository(DataSource dataSource) { + this.dataSource = dataSource; + this.populateRestaurantCache(); + } + + public JdbcRestaurantRepository() { + } + + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + return queryRestaurantCache(merchantNumber); + } + + /** + * Helper method that populates the restaurantCache restaurant object + * caches from the rows in the T_RESTAURANT table. Cached restaurants are indexed + * by their merchant numbers. This method should be called on initialization. + */ + + /* + * TODO-09: Make this method to be invoked after a bean gets created + * - Mark this method with an annotation that will cause it to be + * executed by Spring after constructor & setter initialization has occurred. + * - Re-run the RewardNetworkTests test. You should see the test succeeds. + * - Note that populating the cache is not really a valid + * construction activity, so using a post-construct, rather than + * the constructor, is a better practice. + */ + + void populateRestaurantCache() { + restaurantCache = new HashMap<>(); + String sql = "select MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE from T_RESTAURANT"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + Restaurant restaurant = mapRestaurant(rs); + // index the restaurant by its merchant number + restaurantCache.put(restaurant.getNumber(), restaurant); + } + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred finding by merchant number", e); + } + } + + /** + * Helper method that simply queries the cache of restaurants. + * + * @param merchantNumber + * the restaurant's merchant number + * @return the restaurant + * @throws EmptyResultDataAccessException + * if no restaurant was found with that merchant number + */ + private Restaurant queryRestaurantCache(String merchantNumber) { + Restaurant restaurant = restaurantCache.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } + + /** + * Helper method that clears the cache of restaurants. + * This method should be called when a bean is destroyed. + * + * TODO-10: Add a scheme to check if this method is being invoked + * - Add System.out.println to this method. + * + * TODO-11: Have this method to be invoked before a bean gets destroyed + * - Re-run RewardNetworkTests. + * - Observe this method is not called. + * - Use an appropriate annotation to register this method for a + * destruction lifecycle callback. + * - Re-run the test and you should be able to see + * that this method is now being called. + */ + public void clearRestaurantCache() { + restaurantCache.clear(); + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + * + * @param rs + * the result set with its cursor positioned at the current row + */ + private Restaurant mapRestaurant(ResultSet rs) throws SQLException { + // get the row column data + String name = rs.getString("NAME"); + String number = rs.getString("MERCHANT_NUMBER"); + Percentage benefitPercentage = Percentage.valueOf(rs.getString("BENEFIT_PERCENTAGE")); + // map to the object + Restaurant restaurant = new Restaurant(number, name); + restaurant.setBenefitPercentage(benefitPercentage); + return restaurant; + } +} \ No newline at end of file diff --git a/lab/16-annotations/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/16-annotations/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..aa642ae --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,79 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on a benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + return dining.getAmount().multiplyBy(benefitPercentage); + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage; + } +} \ No newline at end of file diff --git a/lab/16-annotations/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/16-annotations/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/16-annotations/src/main/java/rewards/internal/restaurant/package.html b/lab/16-annotations/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/16-annotations/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/16-annotations/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..4c7fb73 --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,68 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import javax.sql.DataSource; +import java.sql.*; + +/** + * JDBC implementation of a reward repository that + * records the result of a reward transaction by + * inserting a reward confirmation record. + */ + +/* TODO-04: Let this class to be found in component-scanning + * - Annotate the class with an appropriate stereotype annotation + * to cause component-scanning to detect and load this bean. + * - Inject dataSource by annotating setDataSource() method + * with @Autowired. + */ + +public class JdbcRewardRepository implements RewardRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to insert rewards. + * @param dataSource the data source + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + String confirmationNumber = nextConfirmationNumber(); + ps.setString(1, confirmationNumber); + ps.setBigDecimal(2, contribution.getAmount().asBigDecimal()); + ps.setDate(3, new Date(SimpleDate.today().inMilliseconds())); + ps.setString(4, contribution.getAccountNumber()); + ps.setString(5, dining.getMerchantNumber()); + ps.setDate(6, new Date(dining.getDate().inMilliseconds())); + ps.setBigDecimal(7, dining.getAmount().asBigDecimal()); + ps.execute(); + return new RewardConfirmation(confirmationNumber, contribution); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred inserting reward record", e); + } + // Close to prevent database cursor exhaustion + // Close to prevent database connection exhaustion + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getString(1); + } catch (SQLException e) { + throw new RuntimeException("SQL exception getting next confirmation number", e); + } + // Close to prevent database cursor exhaustion + // Close to prevent database cursor exhaustion + // Close to prevent database connection exhaustion + } +} diff --git a/lab/16-annotations/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/16-annotations/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/16-annotations/src/main/java/rewards/internal/reward/package.html b/lab/16-annotations/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/16-annotations/src/main/java/rewards/package.html b/lab/16-annotations/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/16-annotations/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/16-annotations/src/test/java/rewards/RewardNetworkTests.java b/lab/16-annotations/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..e1f85a2 --- /dev/null +++ b/lab/16-annotations/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,75 @@ +package rewards; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ApplicationContext; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A system test that verifies the components of the RewardNetwork + * application work together to reward for dining successfully. + * It uses Spring to bootstrap the application for use in a test environment. + * + * TODO-00: In this lab, you are going to exercise the following: + * - Refactoring the current code that uses Spring configuration with + * @Bean methods so that it uses annotation and component-scanning instead + * - Using constructor injection and setter injection + * - Using @PostConstruct and @PreDestroy + * + * TODO-01: Run this test before making any changes. + * - It should pass. + * Note that this test passes only when all the required + * beans are correctly configured. + */ +public class RewardNetworkTests { + + /** + * The object being tested. + */ + private RewardNetwork rewardNetwork; + + @BeforeEach + public void setUp() { + // Create application context from TestInfrastructureConfig, + // which also imports RewardsConfig + ApplicationContext context = SpringApplication.run(TestInfrastructureConfig.class); + + // Get rewardNetwork bean from the application context + rewardNetwork = context.getBean(RewardNetwork.class); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + // this fails if you have selected an account without beneficiaries! + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} diff --git a/lab/16-annotations/src/test/java/rewards/TestInfrastructureConfig.java b/lab/16-annotations/src/test/java/rewards/TestInfrastructureConfig.java new file mode 100644 index 0000000..bd29e01 --- /dev/null +++ b/lab/16-annotations/src/test/java/rewards/TestInfrastructureConfig.java @@ -0,0 +1,28 @@ +package rewards; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import config.RewardsConfig; + +@Configuration +@Import(RewardsConfig.class) +public class TestInfrastructureConfig { + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ + @Bean + public DataSource dataSource(){ + return + (new EmbeddedDatabaseBuilder()) + .addScript("classpath:rewards/testdb/schema.sql") + .addScript("classpath:rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/16-annotations/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/16-annotations/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..e2677d4 --- /dev/null +++ b/lab/16-annotations/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,71 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import common.money.MonetaryAmount; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/16-annotations/src/test/java/rewards/internal/StubAccountRepository.java b/lab/16-annotations/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..b926be2 --- /dev/null +++ b/lab/16-annotations/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,43 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new EmptyResultDataAccessException(1); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/16-annotations/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/16-annotations/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..ce1f820 --- /dev/null +++ b/lab/16-annotations/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,38 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } +} \ No newline at end of file diff --git a/lab/16-annotations/src/test/java/rewards/internal/StubRewardRepository.java b/lab/16-annotations/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..2487aca --- /dev/null +++ b/lab/16-annotations/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import java.util.Random; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/16-annotations/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java b/lab/16-annotations/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java new file mode 100644 index 0000000..1904001 --- /dev/null +++ b/lab/16-annotations/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java @@ -0,0 +1,73 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC restaurant repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRestaurantRepositoryTests { + + private JdbcRestaurantRepository repository; + + @BeforeEach + public void setUp() { + // simulate the Spring bean initialization lifecycle: + // first, construct the bean + repository = new JdbcRestaurantRepository(); + + // then, inject its dependencies + repository.setDataSource(createTestDataSource()); + + // lastly, initialize the bean + repository.populateRestaurantCache(); + } + + @AfterEach + public void tearDown() { + // simulate the Spring bean destruction lifecycle: + repository.clearRestaurantCache(); + } + + @Test + public void findRestaurantByMerchantNumber() { + Restaurant restaurant = repository.findByMerchantNumber("1234567890"); + assertNotNull(restaurant, "restaurant is null - check your repositories cache"); + assertEquals("1234567890", restaurant.getNumber(),"number is wrong"); + assertEquals("AppleBees", restaurant.getName(), "name is wrong"); + assertEquals(Percentage.valueOf("8%"), restaurant.getBenefitPercentage(), "benefitPercentage is wrong"); + } + + @Test + public void findRestaurantByBogusMerchantNumber() { + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("bogus"); + }); + } + + @Test + public void restaurantCacheClearedAfterDestroy() { + // force early tear down + tearDown(); + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("1234567890"); + }); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/22-aop-solution/build.gradle b/lab/22-aop-solution/build.gradle new file mode 100644 index 0000000..f1e3161 --- /dev/null +++ b/lab/22-aop-solution/build.gradle @@ -0,0 +1,6 @@ +dependencies { + implementation project(':00-rewards-common') + implementation "org.springframework.boot:spring-boot-starter-aop:$springBootVersion" + implementation "org.easymock:easymock:$easyMockVersion" + implementation "com.jamonapi:jamon:$jmonVersion" +} diff --git a/lab/22-aop-solution/pom.xml b/lab/22-aop-solution/pom.xml new file mode 100644 index 0000000..84af378 --- /dev/null +++ b/lab/22-aop-solution/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + 22-aop-solution + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + org.springframework + spring-aspects + + + org.easymock + easymock + + + com.jamonapi + jamon + + + diff --git a/lab/22-aop-solution/src/main/java/config/AspectsConfig.java b/lab/22-aop-solution/src/main/java/config/AspectsConfig.java new file mode 100644 index 0000000..5bbf13b --- /dev/null +++ b/lab/22-aop-solution/src/main/java/config/AspectsConfig.java @@ -0,0 +1,21 @@ +package config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.EnableAspectJAutoProxy; + +import rewards.internal.monitor.MonitorFactory; +import rewards.internal.monitor.jamon.JamonMonitorFactory; + +@Configuration +@ComponentScan(basePackages="rewards.internal.aspects") +@EnableAspectJAutoProxy +public class AspectsConfig { + + @Bean + public MonitorFactory monitorFactory(){ + return new JamonMonitorFactory(); + } + +} diff --git a/lab/22-aop-solution/src/main/java/config/RewardsConfig.java b/lab/22-aop-solution/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..4a06fbc --- /dev/null +++ b/lab/22-aop-solution/src/main/java/config/RewardsConfig.java @@ -0,0 +1,53 @@ +package config; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import rewards.RewardNetwork; +import rewards.internal.RewardNetworkImpl; +import rewards.internal.account.AccountRepository; +import rewards.internal.account.JdbcAccountRepository; +import rewards.internal.restaurant.JdbcRestaurantRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.JdbcRewardRepository; +import rewards.internal.reward.RewardRepository; + +@Configuration +public class RewardsConfig { + + @Autowired + DataSource dataSource; + + @Bean + public RewardNetwork rewardNetwork(){ + return new RewardNetworkImpl( + accountRepository(), + restaurantRepository(), + rewardRepository()); + } + + @Bean + public AccountRepository accountRepository(){ + JdbcAccountRepository repository = new JdbcAccountRepository(); + repository.setDataSource(dataSource); + return repository; + } + + @Bean + public RestaurantRepository restaurantRepository(){ + JdbcRestaurantRepository repository = new JdbcRestaurantRepository(); + repository.setDataSource(dataSource); + return repository; + } + + @Bean + public RewardRepository rewardRepository(){ + JdbcRewardRepository repository = new JdbcRewardRepository(); + repository.setDataSource(dataSource); + return repository; + } + +} diff --git a/lab/22-aop-solution/src/main/java/rewards/AccountContribution.java b/lab/22-aop-solution/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/main/java/rewards/Dining.java b/lab/22-aop-solution/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/main/java/rewards/RewardConfirmation.java b/lab/22-aop-solution/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/main/java/rewards/RewardNetwork.java b/lab/22-aop-solution/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/22-aop-solution/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..8de3772 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,52 @@ +package rewards.internal; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.updateReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/account/Account.java b/lab/22-aop-solution/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..84463f0 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,159 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + try { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } catch (IllegalArgumentException e) { + // total would have been over 100% - return invalid + return false; + } + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. Callers should not attempt to hold on or modify the returned set. + * This method should only be used transitively; for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Returns a single account beneficiary. Callers should not attempt to hold on or modify the returned object. This + * method should only be used transitively; for example, called to facilitate reporting or testing. + * @param name the name of the beneficiary e.g "Annabelle" + * @return the beneficiary object + */ + public Beneficiary getBeneficiary(String name) { + for (Beneficiary b : beneficiaries) { + if (b.getName().equals(name)) { + return b; + } + } + throw new IllegalArgumentException("No such beneficiary with name '" + name + "'"); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/account/AccountRepository.java b/lab/22-aop-solution/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..16c6079 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/account/Beneficiary.java b/lab/22-aop-solution/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java b/lab/22-aop-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java new file mode 100644 index 0000000..f1f009a --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java @@ -0,0 +1,125 @@ +package rewards.internal.account; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.exception.RewardDataAccessException; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Loads accounts from a data source using the JDBC API. + */ +public class JdbcAccountRepository implements AccountRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to load accounts. + * @param dataSource the data source + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Account findByCreditCard(String creditCardNumber) { + String sql = "select a.ID as ID, a.NUMBER as ACCOUNT_NUMBER, a.NAME as ACCOUNT_NAME, c.NUMBER as CREDIT_CARD_NUMBER, b.NAME as BENEFICIARY_NAME, b.ALLOCATION_PERCENTAGE as BENEFICIARY_ALLOCATION_PERCENTAGE, b.SAVINGS as BENEFICIARY_SAVINGS from T_ACCOUNT a, T_ACCOUNT_BENEFICIARY b, T_ACCOUNT_CREDIT_CARD c where ID = b.ACCOUNT_ID and ID = c.ACCOUNT_ID and c.NUMBER = ?"; + Account account = null; + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + try { + conn = dataSource.getConnection(); + ps = conn.prepareStatement(sql); + ps.setString(1, creditCardNumber); + rs = ps.executeQuery(); + account = mapAccount(rs); + } catch (SQLException e) { + throw new RewardDataAccessException("SQL exception occurred finding by credit card number", e); + } finally { + if (rs != null) { + try { + // Close to prevent database cursor exhaustion + rs.close(); + } catch (SQLException ex) { + } + } + if (ps != null) { + try { + // Close to prevent database cursor exhaustion + ps.close(); + } catch (SQLException ex) { + } + } + if (conn != null) { + try { + // Close to prevent database connection exhaustion + conn.close(); + } catch (SQLException ex) { + } + } + } + return account; + } + + public void updateBeneficiaries(Account account) { + String sql = "update T_ACCOUNT_BENEFICIARY SET SAVINGS = ? where ACCOUNT_ID = ? and NAME = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + for (Beneficiary beneficiary : account.getBeneficiaries()) { + ps.setBigDecimal(1, beneficiary.getSavings().asBigDecimal()); + ps.setLong(2, account.getEntityId()); + ps.setString(3, beneficiary.getName()); + ps.executeUpdate(); + } + } catch (SQLException e) { + throw new RewardDataAccessException("SQL exception occurred updating beneficiary savings", e); + } + } + + /** + * Map the rows returned from the join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY to a fully-reconstituted Account + * aggregate. + * @param rs the set of rows returned from the query + * @return the mapped Account aggregate + * @throws SQLException an exception occurred extracting data from the result set + */ + private Account mapAccount(ResultSet rs) throws SQLException { + Account account = null; + while (rs.next()) { + if (account == null) { + String number = rs.getString("ACCOUNT_NUMBER"); + String name = rs.getString("ACCOUNT_NAME"); + account = new Account(number, name); + // set internal entity identifier (primary key) + account.setEntityId(rs.getLong("ID")); + } + account.restoreBeneficiary(mapBeneficiary(rs)); + } + if (account == null) { + // no rows returned - throw an empty result exception + throw new EmptyResultDataAccessException(1); + } + return account; + } + + /** + * Maps the beneficiary columns in a single row to an AllocatedBeneficiary object. + * @param rs the result set with its cursor positioned at the current row + * @return an allocated beneficiary + * @throws SQLException an exception occurred extracting data from the result set + */ + private Beneficiary mapBeneficiary(ResultSet rs) throws SQLException { + String name = rs.getString("BENEFICIARY_NAME"); + MonetaryAmount savings = MonetaryAmount.valueOf(rs.getString("BENEFICIARY_SAVINGS")); + Percentage allocationPercentage = Percentage.valueOf(rs.getString("BENEFICIARY_ALLOCATION_PERCENTAGE")); + return new Beneficiary(name, allocationPercentage, savings); + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/account/package.html b/lab/22-aop-solution/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/aspects/DBExceptionHandlingAspect.java b/lab/22-aop-solution/src/main/java/rewards/internal/aspects/DBExceptionHandlingAspect.java new file mode 100644 index 0000000..f5cadff --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/aspects/DBExceptionHandlingAspect.java @@ -0,0 +1,26 @@ +package rewards.internal.aspects; + +import org.aspectj.lang.annotation.AfterThrowing; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import rewards.internal.exception.RewardDataAccessException; + + +@Aspect +@Component +public class DBExceptionHandlingAspect { + + public static final String EMAIL_FAILURE_MSG = "Failed sending an email to Mister Smith : "; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @AfterThrowing(value="execution(public * rewards.internal.*.*Repository.*(..))", throwing="e") + public void implExceptionHandling(RewardDataAccessException e) { + // Log a failure warning + logger.warn(EMAIL_FAILURE_MSG + e + "\n"); + } + +} diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/aspects/LoggingAspect.java b/lab/22-aop-solution/src/main/java/rewards/internal/aspects/LoggingAspect.java new file mode 100644 index 0000000..c4a4ac9 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/aspects/LoggingAspect.java @@ -0,0 +1,57 @@ +package rewards.internal.aspects; + +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.Signature; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import rewards.internal.monitor.Monitor; +import rewards.internal.monitor.MonitorFactory; + +@Aspect +@Component +public class LoggingAspect { + public final static String BEFORE = "'Before'"; + public final static String AROUND = "'Around'"; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + private final MonitorFactory monitorFactory; + + public LoggingAspect(MonitorFactory monitorFactory) { + this.monitorFactory = monitorFactory; + } + + @Before("execution(public * rewards.internal.*.*Repository.find*(..))") + public void implLogging(JoinPoint joinPoint) { + logger.info(BEFORE + " advice implementation - " + joinPoint.getTarget().getClass() + // + "; Executing before " + joinPoint.getSignature().getName() + // + "() method"); + + } + + @Around("execution(public * rewards.internal.*.*Repository.update*(..))") + public Object monitor(ProceedingJoinPoint repositoryMethod) throws Throwable { + String name = createJoinPointTraceName(repositoryMethod); + Monitor monitor = monitorFactory.start(name); + try { + return repositoryMethod.proceed(); + } finally { + monitor.stop(); + logger.info(AROUND + " advice implementation - " + monitor); + + } + } + + private String createJoinPointTraceName(JoinPoint joinPoint) { + Signature signature = joinPoint.getSignature(); + StringBuilder sb = new StringBuilder(); + sb.append(signature.getDeclaringType().getSimpleName()); + sb.append('.').append(signature.getName()); + return sb.toString(); + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/exception/RewardDataAccessException.java b/lab/22-aop-solution/src/main/java/rewards/internal/exception/RewardDataAccessException.java new file mode 100644 index 0000000..73b2b83 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/exception/RewardDataAccessException.java @@ -0,0 +1,23 @@ +package rewards.internal.exception; + + +@SuppressWarnings("serial") +public class RewardDataAccessException extends RuntimeException{ + + public RewardDataAccessException() { + super(); + } + + public RewardDataAccessException(String message, Throwable cause) { + super(message, cause); + } + + public RewardDataAccessException(String message) { + super(message); + } + + public RewardDataAccessException(Throwable cause) { + super(cause); + } + +} diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/monitor/GlobalMonitorStatistics.java b/lab/22-aop-solution/src/main/java/rewards/internal/monitor/GlobalMonitorStatistics.java new file mode 100644 index 0000000..ff95636 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/monitor/GlobalMonitorStatistics.java @@ -0,0 +1,24 @@ +package rewards.internal.monitor; + +import java.util.Date; + +public interface GlobalMonitorStatistics { + + long getCallsCount(); + + long getTotalCallTime(); + + Date getLastAccessTime(); + + long lastCallTime(String methodName); + + long callCount(String methodName); + + long averageCallTime(String methodName); + + long totalCallTime(String methodName); + + long minimumCallTime(String methodName); + + long maximumCallTime(String methodName); +} diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/monitor/Monitor.java b/lab/22-aop-solution/src/main/java/rewards/internal/monitor/Monitor.java new file mode 100644 index 0000000..2570ccb --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/monitor/Monitor.java @@ -0,0 +1,8 @@ +package rewards.internal.monitor; + +public interface Monitor { + + Monitor start(); + + Monitor stop(); +} diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/monitor/MonitorFactory.java b/lab/22-aop-solution/src/main/java/rewards/internal/monitor/MonitorFactory.java new file mode 100644 index 0000000..ba07f4a --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/monitor/MonitorFactory.java @@ -0,0 +1,6 @@ +package rewards.internal.monitor; + +public interface MonitorFactory { + + Monitor start(String name); +} diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/monitor/MonitorStatistics.java b/lab/22-aop-solution/src/main/java/rewards/internal/monitor/MonitorStatistics.java new file mode 100644 index 0000000..02b9a00 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/monitor/MonitorStatistics.java @@ -0,0 +1,19 @@ +package rewards.internal.monitor; + +public interface MonitorStatistics { + + String getName(); + + long getLastCallTime(); + + long getCallCount(); + + long getAverageCallTime(); + + long getTotalCallTime(); + + long getMinimumCallTime(); + + long getMaximumCallTime(); + +} diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/monitor/jamon/JamonMonitor.java b/lab/22-aop-solution/src/main/java/rewards/internal/monitor/jamon/JamonMonitor.java new file mode 100644 index 0000000..4c40cce --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/monitor/jamon/JamonMonitor.java @@ -0,0 +1,63 @@ +package rewards.internal.monitor.jamon; + +import rewards.internal.monitor.Monitor; +import rewards.internal.monitor.MonitorStatistics; + +public class JamonMonitor implements Monitor, MonitorStatistics { + + private final com.jamonapi.Monitor monitor; + + public JamonMonitor(com.jamonapi.Monitor monitor) { + this.monitor = monitor; + } + + public Monitor start() { + monitor.start(); + return this; + } + + public Monitor stop() { + monitor.stop(); + return this; + } + + public String getName() { + return monitor.getLabel(); + } + + public long getCallCount() { + return (long) monitor.getHits(); + } + + public long getAverageCallTime() { + return (long) monitor.getAvg(); + } + + public long getLastCallTime() { + return (long) monitor.getLastValue(); + } + + public long getMaximumCallTime() { + return (long) monitor.getMax(); + } + + public long getMinimumCallTime() { + return (long) monitor.getMin(); + } + + public long getTotalCallTime() { + return (long) monitor.getTotal(); + } + + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(monitor.getLabel()).append(": "); + sb.append("Last=").append(monitor.getLastValue()).append(", "); + sb.append("Calls=").append(monitor.getHits()).append(", "); + sb.append("Avg=").append(monitor.getAvg()).append(", "); + sb.append("Total=").append(monitor.getTotal()).append(", "); + sb.append("Min=").append(monitor.getMin()).append(", "); + sb.append("Max=").append(monitor.getMax()); + return sb.toString(); + } +} diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/monitor/jamon/JamonMonitorFactory.java b/lab/22-aop-solution/src/main/java/rewards/internal/monitor/jamon/JamonMonitorFactory.java new file mode 100644 index 0000000..fbc6690 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/monitor/jamon/JamonMonitorFactory.java @@ -0,0 +1,59 @@ +package rewards.internal.monitor.jamon; + +import java.util.Date; + +import rewards.internal.monitor.GlobalMonitorStatistics; +import rewards.internal.monitor.Monitor; +import rewards.internal.monitor.MonitorFactory; + +import com.jamonapi.MonitorComposite; + +public class JamonMonitorFactory implements MonitorFactory, GlobalMonitorStatistics { + + private final com.jamonapi.MonitorFactoryInterface monitorFactory = com.jamonapi.MonitorFactory.getFactory(); + + public Monitor start(String name) { + return new JamonMonitor(monitorFactory.start(name)); + } + + public long getCallsCount() { + return (long) getMonitors().getHits(); + } + + public long getTotalCallTime() { + return (long) getMonitors().getTotal(); + } + + public Date getLastAccessTime() { + return getMonitors().getLastAccess(); + } + + public MonitorComposite getMonitors() { + return monitorFactory.getRootMonitor(); + } + + public long averageCallTime(String methodName) { + return (long) monitorFactory.getMonitor(methodName, "ms.").getAvg(); + } + + public long callCount(String methodName) { + return (long) monitorFactory.getMonitor(methodName, "ms.").getHits(); + } + + public long lastCallTime(String methodName) { + return (long) monitorFactory.getMonitor(methodName, "ms.").getLastValue(); + } + + public long maximumCallTime(String methodName) { + return (long) monitorFactory.getMonitor(methodName, "ms.").getMax(); + } + + public long minimumCallTime(String methodName) { + return (long) monitorFactory.getMonitor(methodName, "ms.").getMin(); + } + + public long totalCallTime(String methodName) { + return (long) monitorFactory.getMonitor(methodName, "ms.").getTotal(); + } + +} diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/package.html b/lab/22-aop-solution/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java b/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java new file mode 100644 index 0000000..b7d6d74 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java @@ -0,0 +1,20 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * Determines if benefit is available for an account for dining. + * + * A value object. A strategy. Scoped by the Resturant aggregate. + */ +public interface BenefitAvailabilityPolicy { + + /** + * Calculates if an account is eligible to receive benefits for a dining. + * @param account the account of the member who dined + * @param dining the dining event + * @return benefit availability status + */ + boolean isBenefitAvailableFor(Account account, Dining dining); +} diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java b/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java new file mode 100644 index 0000000..a435646 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java @@ -0,0 +1,157 @@ +package rewards.internal.restaurant; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.Dining; +import rewards.internal.account.Account; +import rewards.internal.exception.RewardDataAccessException; + +import common.money.Percentage; + +/** + * Loads restaurants from a data source using the JDBC API. + */ +public class JdbcRestaurantRepository implements RestaurantRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to load restaurants. + * @param dataSource the data source + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + String sql = "select MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY from T_RESTAURANT where MERCHANT_NUMBER = ?"; + Restaurant restaurant = null; + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + try { + conn = dataSource.getConnection(); + ps = conn.prepareStatement(sql); + ps.setString(1, merchantNumber); + rs = ps.executeQuery(); + advanceToNextRow(rs); + restaurant = mapRestaurant(rs); + } catch (SQLException e) { + throw new RewardDataAccessException("SQL exception occurred finding by merchant number", e); + } finally { + if (rs != null) { + try { + // Close to prevent database cursor exhaustion + rs.close(); + } catch (SQLException ex) { + } + } + if (ps != null) { + try { + // Close to prevent database cursor exhaustion + ps.close(); + } catch (SQLException ex) { + } + } + if (conn != null) { + try { + // Close to prevent database connection exhaustion + conn.close(); + } catch (SQLException ex) { + } + } + } + return restaurant; + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + * @param rs the result set with its cursor positioned at the current row + */ + private Restaurant mapRestaurant(ResultSet rs) throws SQLException { + // get the row column data + String name = rs.getString("NAME"); + String number = rs.getString("MERCHANT_NUMBER"); + Percentage benefitPercentage = Percentage.valueOf(rs.getString("BENEFIT_PERCENTAGE")); + // map to the object + Restaurant restaurant = new Restaurant(number, name); + restaurant.setBenefitPercentage(benefitPercentage); + restaurant.setBenefitAvailabilityPolicy(mapBenefitAvailabilityPolicy(rs)); + return restaurant; + } + + /** + * Advances a ResultSet to the next row and throws an exception if there are no rows. + * @param rs the ResultSet to advance + * @throws EmptyResultDataAccessException if there is no next row + * @throws SQLException + */ + private void advanceToNextRow(ResultSet rs) throws EmptyResultDataAccessException, SQLException { + if (!rs.next()) { + throw new EmptyResultDataAccessException(1); + } + } + + /** + * Helper method that maps benefit availability policy data in the ResultSet to a fully-configured + * {@link BenefitAvailabilityPolicy} object. The key column is 'BENEFIT_AVAILABILITY_POLICY', which is a + * discriminator column containing a string code that identifies the type of policy. Currently supported types are: + * 'A' for 'always available' and 'N' for 'never available'. + * + *

+ * More types could be added easily by enhancing this method. For example, 'W' for 'Weekdays only' or 'M' for 'Max + * Rewards per Month'. Some of these types might require additional database column values to be configured, for + * example a 'MAX_REWARDS_PER_MONTH' data column. + * + * @param rs the result set used to map the policy object from database column values + * @return the matching benefit availability policy + * @throws IllegalArgumentException if the mapping could not be performed + */ + private BenefitAvailabilityPolicy mapBenefitAvailabilityPolicy(ResultSet rs) throws SQLException { + String policyCode = rs.getString("BENEFIT_AVAILABILITY_POLICY"); + if ("A".equals(policyCode)) { + return AlwaysAvailable.INSTANCE; + } else if ("N".equals(policyCode)) { + return NeverAvailable.INSTANCE; + } else { + throw new IllegalArgumentException("Not a supported policy code " + policyCode); + } + } + + /** + * Returns true indicating benefit is always available. + */ + static class AlwaysAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new AlwaysAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + + public String toString() { + return "alwaysAvailable"; + } + } + + /** + * Returns false indicating benefit is never available. + */ + static class NeverAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new NeverAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return false; + } + + public String toString() { + return "neverAvailable"; + } + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..2e73e57 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,102 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on an availability policy and a + * benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + private BenefitAvailabilityPolicy benefitAvailabilityPolicy; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Sets the policy that determines if a dining by an account at this restaurant is eligible for benefit. + * @param benefitAvailabilityPolicy the benefit availability policy + */ + public void setBenefitAvailabilityPolicy(BenefitAvailabilityPolicy benefitAvailabilityPolicy) { + this.benefitAvailabilityPolicy = benefitAvailabilityPolicy; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Returns this restaurant's benefit availability policy. + */ + public BenefitAvailabilityPolicy getBenefitAvailabilityPolicy() { + return benefitAvailabilityPolicy; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + if (benefitAvailabilityPolicy.isBenefitAvailableFor(account, dining)) { + return dining.getAmount().multiplyBy(benefitPercentage); + } else { + return MonetaryAmount.zero(); + } + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage + + ", benefitAvailabilityPolicy = " + benefitAvailabilityPolicy; + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/package.html b/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/22-aop-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..69f2552 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,57 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import javax.sql.DataSource; +import java.sql.*; + +/** + * JDBC implementation of a reward repository that records the result of a reward transaction by inserting a reward + * confirmation record. + */ +public class JdbcRewardRepository implements RewardRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to insert rewards. + * @param dataSource the data source + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public RewardConfirmation updateReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + String confirmationNumber = nextConfirmationNumber(); + ps.setString(1, confirmationNumber); + ps.setBigDecimal(2, contribution.getAmount().asBigDecimal()); + ps.setDate(3, new Date(SimpleDate.today().inMilliseconds())); + ps.setString(4, contribution.getAccountNumber()); + ps.setString(5, dining.getMerchantNumber()); + ps.setDate(6, new Date(dining.getDate().inMilliseconds())); + ps.setBigDecimal(7, dining.getAmount().asBigDecimal()); + ps.execute(); + return new RewardConfirmation(confirmationNumber, contribution); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred inserting reward record", e); + } + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getString(1); + } catch (SQLException e) { + throw new RuntimeException("SQL exception getting next confirmation number", e); + } + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/22-aop-solution/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..5b7d4df --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation updateReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/main/java/rewards/internal/reward/package.html b/lab/22-aop-solution/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/22-aop-solution/src/main/java/rewards/package.html b/lab/22-aop-solution/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/22-aop-solution/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/22-aop-solution/src/test/java/rewards/CaptureSystemOutput.java b/lab/22-aop-solution/src/test/java/rewards/CaptureSystemOutput.java new file mode 100644 index 0000000..c2c169c --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/CaptureSystemOutput.java @@ -0,0 +1,244 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rewards; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.util.ArrayList; +import java.util.List; + +import org.hamcrest.Matcher; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; +import org.junit.jupiter.api.extension.ExtensionContext.Store; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolver; +import org.junit.platform.commons.support.ReflectionSupport; + +/** + * {@code @CaptureSystemOutput} is a JUnit JUpiter extension for capturing + * output to {@code System.out} and {@code System.err} with expectations + * supported via Hamcrest matchers. + * + *

Example Usage

+ * + *
+ * {@literal @}Test
+ * {@literal @}CaptureSystemOutput
+ * void systemOut(OutputCapture outputCapture) {
+ *     outputCapture.expect(containsString("System.out!"));
+ *
+ *     System.out.println("Printed to System.out!");
+ * }
+ * 
+ * {@literal @}Test
+ * {@literal @}CaptureSystemOutput
+ * void systemErr(OutputCapture outputCapture) {
+ *     outputCapture.expect(containsString("System.err!"));
+ *
+ *     System.err.println("Printed to System.err!");
+ * }
+ * 
+ * + *

Based on code from Spring Boot's + * OutputCapture + * rule for JUnit 4 by Phillip Webb and Andy Wilkinson. + * + * @author Sam Brannen + * @author Phillip Webb + * @author Andy Wilkinson + */ +@Target({ TYPE, METHOD }) +@Retention(RUNTIME) +@ExtendWith(CaptureSystemOutput.Extension.class) +public @interface CaptureSystemOutput { + + class Extension implements BeforeEachCallback, AfterEachCallback, ParameterResolver { + + @Override + public void beforeEach(ExtensionContext context) { + getOutputCapture(context).captureOutput(); + } + + @Override + public void afterEach(ExtensionContext context) { + OutputCapture outputCapture = getOutputCapture(context); + try { + if (!outputCapture.matchers.isEmpty()) { + String output = outputCapture.toString(); + assertThat(output, allOf(outputCapture.matchers)); + } + } + finally { + outputCapture.releaseOutput(); + } + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + boolean isTestMethodLevel = extensionContext.getTestMethod().isPresent(); + boolean isOutputCapture = parameterContext.getParameter().getType() == OutputCapture.class; + return isTestMethodLevel && isOutputCapture; + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + return getOutputCapture(extensionContext); + } + + private OutputCapture getOutputCapture(ExtensionContext context) { + return getOrComputeIfAbsent(getStore(context), OutputCapture.class); + } + + private V getOrComputeIfAbsent(Store store, Class type) { + return store.getOrComputeIfAbsent(type, ReflectionSupport::newInstance, type); + } + + private Store getStore(ExtensionContext context) { + return context.getStore(Namespace.create(getClass(), context.getRequiredTestMethod())); + } + + } + + /** + * {@code OutputCapture} captures output to {@code System.out} and {@code System.err}. + * + *

To obtain an instance of {@code OutputCapture}, declare a parameter of type + * {@code OutputCapture} in a JUnit Jupiter {@code @Test}, {@code @BeforeEach}, + * or {@code @AfterEach} method. + * + *

{@linkplain #expect Expectations} are supported via Hamcrest matchers. + * + *

To obtain all output to {@code System.out} and {@code System.err}, simply + * invoke {@link #toString()}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Sam Brannen + */ + static class OutputCapture { + + private final List> matchers = new ArrayList<>(); + + private CaptureOutputStream captureOut; + + private CaptureOutputStream captureErr; + + private ByteArrayOutputStream copy; + + void captureOutput() { + this.copy = new ByteArrayOutputStream(); + this.captureOut = new CaptureOutputStream(System.out, this.copy); + this.captureErr = new CaptureOutputStream(System.err, this.copy); + System.setOut(new PrintStream(this.captureOut)); + System.setErr(new PrintStream(this.captureErr)); + } + + void releaseOutput() { + System.setOut(this.captureOut.getOriginal()); + System.setErr(this.captureErr.getOriginal()); + this.copy = null; + } + + private void flush() { + try { + this.captureOut.flush(); + this.captureErr.flush(); + } + catch (IOException ex) { + // ignore + } + } + + /** + * Verify that the captured output is matched by the supplied {@code matcher}. + * + *

Verification is performed after the test method has executed. + * + * @param matcher the matcher + */ + public void expect(Matcher matcher) { + this.matchers.add(matcher); + } + + /** + * Return all captured output to {@code System.out} and {@code System.err} + * as a single string. + */ + @Override + public String toString() { + flush(); + return this.copy.toString(); + } + + private static class CaptureOutputStream extends OutputStream { + + private final PrintStream original; + + private final OutputStream copy; + + CaptureOutputStream(PrintStream original, OutputStream copy) { + this.original = original; + this.copy = copy; + } + + PrintStream getOriginal() { + return this.original; + } + + @Override + public void write(int b) throws IOException { + this.copy.write(b); + this.original.write(b); + this.original.flush(); + } + + @Override + public void write(byte[] b) throws IOException { + write(b, 0, b.length); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + this.copy.write(b, off, len); + this.original.write(b, off, len); + } + + @Override + public void flush() throws IOException { + this.copy.flush(); + this.original.flush(); + } + + } + + } + +} diff --git a/lab/22-aop-solution/src/test/java/rewards/DBExceptionHandlingAspectTests.java b/lab/22-aop-solution/src/test/java/rewards/DBExceptionHandlingAspectTests.java new file mode 100644 index 0000000..5bef106 --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/DBExceptionHandlingAspectTests.java @@ -0,0 +1,37 @@ +package rewards; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import rewards.CaptureSystemOutput.OutputCapture; +import rewards.internal.account.AccountRepository; +import rewards.internal.aspects.DBExceptionHandlingAspect; +import rewards.internal.exception.RewardDataAccessException; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertThrows; + + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = { DbExceptionTestConfig.class }) +public class DBExceptionHandlingAspectTests { + + @Autowired + AccountRepository repository; + + @Test + @CaptureSystemOutput + public void testReportException(OutputCapture capture) { + assertThrows(RewardDataAccessException.class, () -> { + repository.findByCreditCard("1234123412341234"); + }); + + // The error message should have been logged to the console as a warning + assertThat(capture.toString(), containsString(DBExceptionHandlingAspect.EMAIL_FAILURE_MSG)); + } + +} diff --git a/lab/22-aop-solution/src/test/java/rewards/DbExceptionTestConfig.java b/lab/22-aop-solution/src/test/java/rewards/DbExceptionTestConfig.java new file mode 100644 index 0000000..33b5afd --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/DbExceptionTestConfig.java @@ -0,0 +1,31 @@ +package rewards; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import config.AspectsConfig; +import config.RewardsConfig; + + +@Configuration +@Import({RewardsConfig.class,AspectsConfig.class}) +public class DbExceptionTestConfig { + + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ + @Bean + public DataSource dataSource(){ + return + (new EmbeddedDatabaseBuilder()).setName("rewards-dbexception") + // No scripts added. This will cause an exception. + .build(); + } + +} diff --git a/lab/22-aop-solution/src/test/java/rewards/LoggingAspectTests.java b/lab/22-aop-solution/src/test/java/rewards/LoggingAspectTests.java new file mode 100644 index 0000000..82cf892 --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/LoggingAspectTests.java @@ -0,0 +1,33 @@ +package rewards; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import rewards.CaptureSystemOutput.OutputCapture; +import rewards.internal.account.AccountRepository; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = { SystemTestConfig.class }) +public class LoggingAspectTests { + + @Autowired + AccountRepository repository; + + @Test + @CaptureSystemOutput + public void testLogger(OutputCapture capture){ + repository.findByCreditCard("1234123412341234"); + + // AOP VERIFICATION + // LoggingAspect should have output an INFO message to console + String consoleOutput = capture.toString(); + assertTrue(consoleOutput.startsWith("INFO")); + assertTrue(consoleOutput.contains("rewards.internal.aspects.LoggingAspect")); + } +} diff --git a/lab/22-aop-solution/src/test/java/rewards/RewardNetworkTests.java b/lab/22-aop-solution/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..217e4ce --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,104 @@ +package rewards; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import rewards.CaptureSystemOutput.OutputCapture; +import rewards.internal.aspects.LoggingAspect; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A system test that verifies the components of the RewardNetwork application + * work together to reward for dining successfully. Uses Spring to bootstrap the + * application for use in a test environment. + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes={SystemTestConfig.class}) +public class RewardNetworkTests { + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + @Test + @CaptureSystemOutput + public void testRewardForDining(OutputCapture capture) { + // create a new dining of 100.00 charged to credit card + // '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 + // distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + + checkConsoleOutput(capture, 4); + } + + /** + * Not only must the code run, but the LoggingAspect should generate logging + * output to the console. + */ + private void checkConsoleOutput(OutputCapture capture, int expectedMatches) { + // AOP VERIFICATION + // Expecting 4 lines of output from the LoggingAspect to console + String[] consoleOutput = capture.toString().split("\n"); + int matches = 0; + + for (String line : consoleOutput) { + if (line.contains("rewards.internal.aspects.LoggingAspect")) { + if (line.contains(LoggingAspect.BEFORE)) { + if (line.contains("JdbcAccountRepository") && line.contains("findByCreditCard")) + // Before aspect invoked for + // JdbcAccountRepository.findByCreditCard + matches++; + else if (line.contains("JdbcRestaurantRepository") && line.contains("findByMerchantNumber")) + // Before aspect invoked for + // JdbcRestaurantRepository.findByMerchantNumber + matches++; + } else if (line.contains(LoggingAspect.AROUND)) { + if (line.contains("AccountRepository") && line.contains("updateBeneficiaries")) + // Around aspect invoked for + // AccountRepository.updateBeneficiaries + matches++; + else if (line.contains("Around") && line.contains("RewardRepository") + && line.contains("updateReward")) + // Around aspect invoked for + // RewardRepository.updateReward + matches++; + } + } + } + + assertEquals(expectedMatches, matches); + } + +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/test/java/rewards/SystemTestConfig.java b/lab/22-aop-solution/src/test/java/rewards/SystemTestConfig.java new file mode 100644 index 0000000..4808b80 --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/SystemTestConfig.java @@ -0,0 +1,32 @@ +package rewards; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import config.AspectsConfig; +import config.RewardsConfig; + + +@Configuration +@Import({RewardsConfig.class,AspectsConfig.class}) +public class SystemTestConfig { + + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ + @Bean + public DataSource dataSource(){ + return + (new EmbeddedDatabaseBuilder()) + .addScript("classpath:rewards/testdb/schema.sql") + .addScript("classpath:rewards/testdb/data.sql") + .build(); + } + +} diff --git a/lab/22-aop-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/22-aop-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..98b7353 --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,72 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/test/java/rewards/internal/StubAccountRepository.java b/lab/22-aop-solution/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..b926be2 --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,43 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new EmptyResultDataAccessException(1); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/22-aop-solution/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..418516d --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,53 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.Dining; +import rewards.internal.account.Account; +import rewards.internal.restaurant.BenefitAvailabilityPolicy; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new AlwaysReturnsTrue()); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } + + /** + * A simple "dummy" benefit availability policy that always returns true. Only useful for testing--a real + * availability policy might consider many factors such as the day of week of the dining, or the account's reward + * history for the current month. + */ + private static class AlwaysReturnsTrue implements BenefitAvailabilityPolicy { + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/test/java/rewards/internal/StubRewardRepository.java b/lab/22-aop-solution/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..d4871f8 --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import java.util.Random; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation updateReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/test/java/rewards/internal/account/AccountTests.java b/lab/22-aop-solution/src/test/java/rewards/internal/account/AccountTests.java new file mode 100644 index 0000000..4075654 --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/internal/account/AccountTests.java @@ -0,0 +1,57 @@ +package rewards.internal.account; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for the Account class that verify Account behavior works in isolation. + */ +public class AccountTests { + + private final Account account = new Account("1", "Keith and Keri Donald"); + + @Test + public void accountIsValid() { + // setup account with a valid set of beneficiaries to prepare for testing + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + assertTrue(account.isValid()); + } + + @Test + public void accountIsInvalidWithNoBeneficiaries() { + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreOver100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreUnder100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("25%")); + assertFalse(account.isValid()); + } + + @Test + public void makeContribution() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("100.00")); + assertEquals(contribution.getAmount(), MonetaryAmount.valueOf("100.00")); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java b/lab/22-aop-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java new file mode 100644 index 0000000..4326571 --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java @@ -0,0 +1,96 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC account repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcAccountRepositoryTests { + + private JdbcAccountRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + repository = new JdbcAccountRepository(); + repository.setDataSource(dataSource); + } + + @Test + public void testFindAccountByCreditCard() { + Account account = repository.findByCreditCard("1234123412341234"); + // assert the returned account contains what you expect given the state of the database + assertNotNull(account, "account should never be null"); + assertEquals(Long.valueOf(0), account.getEntityId(), "wrong entity id"); + assertEquals("123456789", account.getNumber(), "wrong account number"); + assertEquals("Keith and Keri Donald", account.getName(), "wrong name"); + assertEquals(2, account.getBeneficiaries().size(), "wrong beneficiary collection size"); + + Beneficiary b1 = account.getBeneficiary("Annabelle"); + assertNotNull(b1, "Annabelle should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b1.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b1.getAllocationPercentage(), "wrong allocation percentage"); + + Beneficiary b2 = account.getBeneficiary("Corgan"); + assertNotNull(b2, "Corgan should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b2.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b2.getAllocationPercentage(), "wrong allocation percentage"); + } + + @Test + public void testFindAccountByCreditCardNoAccount() { + assertThrows(EmptyResultDataAccessException.class, () -> { + repository.findByCreditCard("bogus"); + }); + } + + @Test + public void testUpdateBeneficiaries() throws SQLException { + Account account = repository.findByCreditCard("1234123412341234"); + account.makeContribution(MonetaryAmount.valueOf("8.00")); + repository.updateBeneficiaries(account); + verifyBeneficiaryTableUpdated(); + } + + private void verifyBeneficiaryTableUpdated() throws SQLException { + String sql = "select SAVINGS from T_ACCOUNT_BENEFICIARY where NAME = ? and ACCOUNT_ID = ?"; + PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql); + + // assert Annabelle has $4.00 savings now + stmt.setString(1, "Annabelle"); + stmt.setLong(2, 0L); + ResultSet rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + + // assert Corgan has $4.00 savings now + stmt.setString(1, "Corgan"); + stmt.setLong(2, 0L); + rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/22-aop-solution/src/test/java/rewards/internal/aspects/RepositoryPerformanceLogTest.java b/lab/22-aop-solution/src/test/java/rewards/internal/aspects/RepositoryPerformanceLogTest.java new file mode 100644 index 0000000..996ad44 --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/internal/aspects/RepositoryPerformanceLogTest.java @@ -0,0 +1,36 @@ +package rewards.internal.aspects; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.Signature; +import org.easymock.EasyMock; +import org.junit.jupiter.api.Test; + +import rewards.internal.monitor.jamon.JamonMonitorFactory; + +/** + * Unit test to test the behavior of the RepositoryPerformanceMonitor aspect in isolation. + */ +public class RepositoryPerformanceLogTest { + + @Test + public void testMonitor() throws Throwable { + JamonMonitorFactory monitorFactory = new JamonMonitorFactory(); + LoggingAspect performanceMonitor = new LoggingAspect(monitorFactory); + Signature signature = EasyMock.createMock(Signature.class); + ProceedingJoinPoint targetMethod = EasyMock.createMock(ProceedingJoinPoint.class); + + expect(targetMethod.getSignature()).andReturn(signature); + expect(signature.getDeclaringType()).andReturn(Object.class); + expect(signature.getName()).andReturn("hashCode"); + expect(targetMethod.proceed()).andReturn(new Object()); + + replay(signature, targetMethod); + performanceMonitor.monitor(targetMethod); + verify(signature, targetMethod); + } + +} diff --git a/lab/22-aop-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java b/lab/22-aop-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java new file mode 100644 index 0000000..565dc1b --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java @@ -0,0 +1,52 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC restaurant repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRestaurantRepositoryTests { + + private JdbcRestaurantRepository repository; + + @BeforeEach + public void setUp() { + repository = new JdbcRestaurantRepository(); + repository.setDataSource(createTestDataSource()); + } + + @Test + public void testFindRestaurantByMerchantNumber() { + Restaurant restaurant = repository.findByMerchantNumber("1234567890"); + assertNotNull(restaurant, "the restaurant should never be null"); + assertEquals("1234567890", restaurant.getNumber(), "the merchant number is wrong"); + assertEquals("AppleBees", restaurant.getName(), "the name is wrong"); + assertEquals(Percentage.valueOf("8%"), restaurant.getBenefitPercentage(), "the benefitPercentage is wrong"); + assertEquals(JdbcRestaurantRepository.AlwaysAvailable.INSTANCE, + restaurant.getBenefitAvailabilityPolicy(), "the benefit availability policy is wrong"); + } + + @Test + public void testFindRestaurantByBogusMerchantNumber() { + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("bogus"); + }); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/22-aop-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java b/lab/22-aop-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java new file mode 100644 index 0000000..93c2496 --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java @@ -0,0 +1,71 @@ +package rewards.internal.restaurant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for exercising the behavior of the Restaurant aggregate entity. A restaurant calculates a benefit to award + * to an account for dining based on an availability policy and benefit percentage. + */ +public class RestaurantTests { + + private Restaurant restaurant; + + private Account account; + + private Dining dining; + + @BeforeEach + public void setUp() { + // configure the restaurant, the object being tested + restaurant = new Restaurant("1234567890", "AppleBee's"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(true)); + // configure supporting objects needed by the restaurant + account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle"); + dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + } + + @Test + public void testCalcuateBenefitFor() { + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert 8.00 eligible for reward + assertEquals(MonetaryAmount.valueOf("8.00"), benefit); + } + + @Test + public void testNoBenefitAvailable() { + // configure stub that always returns false + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(false)); + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert zero eligible for reward + assertEquals(MonetaryAmount.valueOf("0.00"), benefit); + } + + /** + * A simple "dummy" benefit availability policy containing a single flag used to determine if benefit is available. + * Only useful for testing--a real availability policy might consider many factors such as the day of week of the + * dining, or the account's reward history for the current month. + */ + private static class StubBenefitAvailibilityPolicy implements BenefitAvailabilityPolicy { + + private final boolean isBenefitAvailable; + + public StubBenefitAvailibilityPolicy(boolean isBenefitAvailable) { + this.isBenefitAvailable = isBenefitAvailable; + } + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return isBenefitAvailable; + } + } +} \ No newline at end of file diff --git a/lab/22-aop-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java b/lab/22-aop-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java new file mode 100644 index 0000000..1ffc0a9 --- /dev/null +++ b/lab/22-aop-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java @@ -0,0 +1,80 @@ +package rewards.internal.reward; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.Account; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Tests the JDBC reward repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRewardRepositoryTests { + + private JdbcRewardRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + repository = new JdbcRewardRepository(); + dataSource = createTestDataSource(); + repository.setDataSource(dataSource); + } + + @Test public void createReward() throws SQLException { + Dining dining = Dining.createDining("100.00", "1234123412341234", "0123456789"); + + Account account = new Account("1", "Keith and Keri Donald"); + account.setEntityId(0L); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("8.00")); + RewardConfirmation confirmation = repository.updateReward(contribution, dining); + assertNotNull(confirmation, "confirmation should not be null"); + assertNotNull("confirmation number should not be null", confirmation.getConfirmationNumber()); + assertEquals(contribution, confirmation.getAccountContribution(), "wrong contribution object"); + verifyRewardInserted(confirmation, dining); + } + + private void verifyRewardInserted(RewardConfirmation confirmation, Dining dining) throws SQLException { + assertEquals(1, getRewardCount()); + Statement stmt = dataSource.getConnection().createStatement(); + ResultSet rs = stmt.executeQuery("select REWARD_AMOUNT from T_REWARD where CONFIRMATION_NUMBER = '" + + confirmation.getConfirmationNumber() + "'"); + rs.next(); + assertEquals(confirmation.getAccountContribution().getAmount(), MonetaryAmount.valueOf(rs.getString(1))); + } + + private int getRewardCount() throws SQLException { + Statement stmt = dataSource.getConnection().createStatement(); + ResultSet rs = stmt.executeQuery("select count(*) from T_REWARD"); + rs.next(); + return rs.getInt(1); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/22-aop/build.gradle b/lab/22-aop/build.gradle new file mode 100644 index 0000000..f1e3161 --- /dev/null +++ b/lab/22-aop/build.gradle @@ -0,0 +1,6 @@ +dependencies { + implementation project(':00-rewards-common') + implementation "org.springframework.boot:spring-boot-starter-aop:$springBootVersion" + implementation "org.easymock:easymock:$easyMockVersion" + implementation "com.jamonapi:jamon:$jmonVersion" +} diff --git a/lab/22-aop/pom.xml b/lab/22-aop/pom.xml new file mode 100644 index 0000000..119c804 --- /dev/null +++ b/lab/22-aop/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + 22-aop + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + org.springframework + spring-aspects + + + org.easymock + easymock + + + com.jamonapi + jamon + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/RewardNetworkTests.java + **/DBExceptionHandlingAspectTests.java + **/LoggingAspectTests.java + **/RepositoryPerformanceLogTest.java + + + + + + diff --git a/lab/22-aop/src/main/java/config/AspectsConfig.java b/lab/22-aop/src/main/java/config/AspectsConfig.java new file mode 100644 index 0000000..a97f880 --- /dev/null +++ b/lab/22-aop/src/main/java/config/AspectsConfig.java @@ -0,0 +1,25 @@ +package config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import rewards.internal.monitor.MonitorFactory; +import rewards.internal.monitor.jamon.JamonMonitorFactory; + +// TODO-04: Update Aspect related configuration +// - Add a class-level annotation to scan for components +// located in the rewards.internal.aspects package. +// - Add @EnableAspectJAutoProxy to this class to instruct Spring +// to process beans that have the @Aspect annotation. +// (Note that this annotation is redundant for Spring Boot +// application since it will be automatically added through +// auto configuration.) +@Configuration +public class AspectsConfig { + + @Bean + public MonitorFactory monitorFactory(){ + return new JamonMonitorFactory(); + } + +} diff --git a/lab/22-aop/src/main/java/config/RewardsConfig.java b/lab/22-aop/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..4a06fbc --- /dev/null +++ b/lab/22-aop/src/main/java/config/RewardsConfig.java @@ -0,0 +1,53 @@ +package config; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import rewards.RewardNetwork; +import rewards.internal.RewardNetworkImpl; +import rewards.internal.account.AccountRepository; +import rewards.internal.account.JdbcAccountRepository; +import rewards.internal.restaurant.JdbcRestaurantRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.JdbcRewardRepository; +import rewards.internal.reward.RewardRepository; + +@Configuration +public class RewardsConfig { + + @Autowired + DataSource dataSource; + + @Bean + public RewardNetwork rewardNetwork(){ + return new RewardNetworkImpl( + accountRepository(), + restaurantRepository(), + rewardRepository()); + } + + @Bean + public AccountRepository accountRepository(){ + JdbcAccountRepository repository = new JdbcAccountRepository(); + repository.setDataSource(dataSource); + return repository; + } + + @Bean + public RestaurantRepository restaurantRepository(){ + JdbcRestaurantRepository repository = new JdbcRestaurantRepository(); + repository.setDataSource(dataSource); + return repository; + } + + @Bean + public RewardRepository rewardRepository(){ + JdbcRewardRepository repository = new JdbcRewardRepository(); + repository.setDataSource(dataSource); + return repository; + } + +} diff --git a/lab/22-aop/src/main/java/rewards/AccountContribution.java b/lab/22-aop/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/22-aop/src/main/java/rewards/Dining.java b/lab/22-aop/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/22-aop/src/main/java/rewards/RewardConfirmation.java b/lab/22-aop/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/22-aop/src/main/java/rewards/RewardNetwork.java b/lab/22-aop/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/22-aop/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/22-aop/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..8de3772 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,52 @@ +package rewards.internal; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.updateReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/22-aop/src/main/java/rewards/internal/account/Account.java b/lab/22-aop/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..84463f0 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,159 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + try { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } catch (IllegalArgumentException e) { + // total would have been over 100% - return invalid + return false; + } + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. Callers should not attempt to hold on or modify the returned set. + * This method should only be used transitively; for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Returns a single account beneficiary. Callers should not attempt to hold on or modify the returned object. This + * method should only be used transitively; for example, called to facilitate reporting or testing. + * @param name the name of the beneficiary e.g "Annabelle" + * @return the beneficiary object + */ + public Beneficiary getBeneficiary(String name) { + for (Beneficiary b : beneficiaries) { + if (b.getName().equals(name)) { + return b; + } + } + throw new IllegalArgumentException("No such beneficiary with name '" + name + "'"); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/22-aop/src/main/java/rewards/internal/account/AccountRepository.java b/lab/22-aop/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..16c6079 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/22-aop/src/main/java/rewards/internal/account/Beneficiary.java b/lab/22-aop/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/22-aop/src/main/java/rewards/internal/account/JdbcAccountRepository.java b/lab/22-aop/src/main/java/rewards/internal/account/JdbcAccountRepository.java new file mode 100644 index 0000000..274cf35 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/account/JdbcAccountRepository.java @@ -0,0 +1,127 @@ +package rewards.internal.account; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.springframework.dao.EmptyResultDataAccessException; +import rewards.internal.exception.RewardDataAccessException; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Loads accounts from a data source using the JDBC API. + */ +public class JdbcAccountRepository implements AccountRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to load accounts. + * + * @param dataSource the data source + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Account findByCreditCard(String creditCardNumber) { + String sql = "select a.ID as ID, a.NUMBER as ACCOUNT_NUMBER, a.NAME as ACCOUNT_NAME, c.NUMBER as CREDIT_CARD_NUMBER, b.NAME as BENEFICIARY_NAME, b.ALLOCATION_PERCENTAGE as BENEFICIARY_ALLOCATION_PERCENTAGE, b.SAVINGS as BENEFICIARY_SAVINGS from T_ACCOUNT a, T_ACCOUNT_BENEFICIARY b, T_ACCOUNT_CREDIT_CARD c where ID = b.ACCOUNT_ID and ID = c.ACCOUNT_ID and c.NUMBER = ?"; + Account account = null; + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + try { + conn = dataSource.getConnection(); + ps = conn.prepareStatement(sql); + ps.setString(1, creditCardNumber); + rs = ps.executeQuery(); + account = mapAccount(rs); + } catch (SQLException e) { + throw new RewardDataAccessException("SQL exception occurred finding by credit card number", e); + } finally { + if (rs != null) { + try { + // Close to prevent database cursor exhaustion + rs.close(); + } catch (SQLException ex) { + } + } + if (ps != null) { + try { + // Close to prevent database cursor exhaustion + ps.close(); + } catch (SQLException ex) { + } + } + if (conn != null) { + try { + // Close to prevent database connection exhaustion + conn.close(); + } catch (SQLException ex) { + } + } + } + return account; + } + + public void updateBeneficiaries(Account account) { + String sql = "update T_ACCOUNT_BENEFICIARY SET SAVINGS = ? where ACCOUNT_ID = ? and NAME = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + for (Beneficiary beneficiary : account.getBeneficiaries()) { + ps.setBigDecimal(1, beneficiary.getSavings().asBigDecimal()); + ps.setLong(2, account.getEntityId()); + ps.setString(3, beneficiary.getName()); + ps.executeUpdate(); + } + } catch (SQLException e) { + throw new RewardDataAccessException("SQL exception occurred updating beneficiary savings", e); + } + } + + /** + * Map the rows returned from the join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY to a fully-reconstituted Account + * aggregate. + * + * @param rs the set of rows returned from the query + * @return the mapped Account aggregate + * @throws SQLException an exception occurred extracting data from the result set + */ + private Account mapAccount(ResultSet rs) throws SQLException { + Account account = null; + while (rs.next()) { + if (account == null) { + String number = rs.getString("ACCOUNT_NUMBER"); + String name = rs.getString("ACCOUNT_NAME"); + account = new Account(number, name); + // set internal entity identifier (primary key) + account.setEntityId(rs.getLong("ID")); + } + account.restoreBeneficiary(mapBeneficiary(rs)); + } + if (account == null) { + // no rows returned - throw an empty result exception + throw new EmptyResultDataAccessException(1); + } + return account; + } + + /** + * Maps the beneficiary columns in a single row to an AllocatedBeneficiary object. + * + * @param rs the result set with its cursor positioned at the current row + * @return an allocated beneficiary + * @throws SQLException an exception occurred extracting data from the result set + */ + private Beneficiary mapBeneficiary(ResultSet rs) throws SQLException { + String name = rs.getString("BENEFICIARY_NAME"); + MonetaryAmount savings = MonetaryAmount.valueOf(rs.getString("BENEFICIARY_SAVINGS")); + Percentage allocationPercentage = Percentage.valueOf(rs.getString("BENEFICIARY_ALLOCATION_PERCENTAGE")); + return new Beneficiary(name, allocationPercentage, savings); + } +} \ No newline at end of file diff --git a/lab/22-aop/src/main/java/rewards/internal/account/package.html b/lab/22-aop/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/22-aop/src/main/java/rewards/internal/aspects/DBExceptionHandlingAspect.java b/lab/22-aop/src/main/java/rewards/internal/aspects/DBExceptionHandlingAspect.java new file mode 100644 index 0000000..681ba83 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/aspects/DBExceptionHandlingAspect.java @@ -0,0 +1,33 @@ +package rewards.internal.aspects; + +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import rewards.internal.exception.RewardDataAccessException; + + +@Aspect +public class DBExceptionHandlingAspect { + + public static final String EMAIL_FAILURE_MSG = "Failed sending an email to Mister Smith : "; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + + // TODO-10 (Optional): Use AOP to log an exception. + // (Steps 10, 11 and 12 are optional, skip them if you are short on time) + // + // - Configure this advice method to enable logging of + // exceptions thrown by Repository class methods. + // - Select the advice type that seems most appropriate. + + public void implExceptionHandling(RewardDataAccessException e) { + // Log a failure warning + logger.warn(EMAIL_FAILURE_MSG + e + "\n"); + } + + // TODO-11 (Optional): Annotate this class as a Spring-managed bean. + // - Note that we enabled component scanning in an earlier step. + +} diff --git a/lab/22-aop/src/main/java/rewards/internal/aspects/LoggingAspect.java b/lab/22-aop/src/main/java/rewards/internal/aspects/LoggingAspect.java new file mode 100644 index 0000000..00a27d4 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/aspects/LoggingAspect.java @@ -0,0 +1,77 @@ +package rewards.internal.aspects; + +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.Signature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import rewards.internal.monitor.Monitor; +import rewards.internal.monitor.MonitorFactory; + +// TODO-02: Use AOP to log a message before +// any repository's find...() method is invoked. +// - Add an appropriate annotation to this class to indicate this class is an aspect. +// - Also make it as a component. +// - Optionally place @Autowired annotation on the constructor +// where `MonitorFactory` dependency is being injected. +// (It is optional since there is only a single constructor in the class.) + +public class LoggingAspect { + public final static String BEFORE = "'Before'"; + public final static String AROUND = "'Around'"; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + private final MonitorFactory monitorFactory; + + + public LoggingAspect(MonitorFactory monitorFactory) { + this.monitorFactory = monitorFactory; + } + + + // TODO-03: Write Pointcut Expression + // - Decide which advice type is most appropriate + // - Write a pointcut expression that selects only find* methods on + // our repository classes. + + public void implLogging(JoinPoint joinPoint) { + // Do not modify this log message or the test will fail + logger.info(BEFORE + " advice implementation - " + joinPoint.getTarget().getClass() + // + "; Executing before " + joinPoint.getSignature().getName() + // + "() method"); + } + + + // TODO-07: Use AOP to time update...() methods. + // - Mark this method as an around advice. + // - Write a pointcut expression to match on all update* methods + // on all Repository classes. + + public Object monitor(ProceedingJoinPoint repositoryMethod) throws Throwable { + String name = createJoinPointTraceName(repositoryMethod); + Monitor monitor = monitorFactory.start(name); + try { + // Invoke repository method ... + + // TODO-08: Add the logic to proceed with the target method invocation. + // - Be sure to return the target method's return value to the caller + // and delete the line below. + + return new String("Delete this line after completing TODO-08"); + + } finally { + monitor.stop(); + // Do not modify this log message or the test will fail + logger.info(AROUND + " advice implementation - " + monitor); + } + } + + private String createJoinPointTraceName(JoinPoint joinPoint) { + Signature signature = joinPoint.getSignature(); + StringBuilder sb = new StringBuilder(); + sb.append(signature.getDeclaringType().getSimpleName()); + sb.append('.').append(signature.getName()); + return sb.toString(); + } +} \ No newline at end of file diff --git a/lab/22-aop/src/main/java/rewards/internal/exception/RewardDataAccessException.java b/lab/22-aop/src/main/java/rewards/internal/exception/RewardDataAccessException.java new file mode 100644 index 0000000..73b2b83 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/exception/RewardDataAccessException.java @@ -0,0 +1,23 @@ +package rewards.internal.exception; + + +@SuppressWarnings("serial") +public class RewardDataAccessException extends RuntimeException{ + + public RewardDataAccessException() { + super(); + } + + public RewardDataAccessException(String message, Throwable cause) { + super(message, cause); + } + + public RewardDataAccessException(String message) { + super(message); + } + + public RewardDataAccessException(Throwable cause) { + super(cause); + } + +} diff --git a/lab/22-aop/src/main/java/rewards/internal/monitor/GlobalMonitorStatistics.java b/lab/22-aop/src/main/java/rewards/internal/monitor/GlobalMonitorStatistics.java new file mode 100644 index 0000000..ff95636 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/monitor/GlobalMonitorStatistics.java @@ -0,0 +1,24 @@ +package rewards.internal.monitor; + +import java.util.Date; + +public interface GlobalMonitorStatistics { + + long getCallsCount(); + + long getTotalCallTime(); + + Date getLastAccessTime(); + + long lastCallTime(String methodName); + + long callCount(String methodName); + + long averageCallTime(String methodName); + + long totalCallTime(String methodName); + + long minimumCallTime(String methodName); + + long maximumCallTime(String methodName); +} diff --git a/lab/22-aop/src/main/java/rewards/internal/monitor/Monitor.java b/lab/22-aop/src/main/java/rewards/internal/monitor/Monitor.java new file mode 100644 index 0000000..2570ccb --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/monitor/Monitor.java @@ -0,0 +1,8 @@ +package rewards.internal.monitor; + +public interface Monitor { + + Monitor start(); + + Monitor stop(); +} diff --git a/lab/22-aop/src/main/java/rewards/internal/monitor/MonitorFactory.java b/lab/22-aop/src/main/java/rewards/internal/monitor/MonitorFactory.java new file mode 100644 index 0000000..ba07f4a --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/monitor/MonitorFactory.java @@ -0,0 +1,6 @@ +package rewards.internal.monitor; + +public interface MonitorFactory { + + Monitor start(String name); +} diff --git a/lab/22-aop/src/main/java/rewards/internal/monitor/MonitorStatistics.java b/lab/22-aop/src/main/java/rewards/internal/monitor/MonitorStatistics.java new file mode 100644 index 0000000..02b9a00 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/monitor/MonitorStatistics.java @@ -0,0 +1,19 @@ +package rewards.internal.monitor; + +public interface MonitorStatistics { + + String getName(); + + long getLastCallTime(); + + long getCallCount(); + + long getAverageCallTime(); + + long getTotalCallTime(); + + long getMinimumCallTime(); + + long getMaximumCallTime(); + +} diff --git a/lab/22-aop/src/main/java/rewards/internal/monitor/jamon/JamonMonitor.java b/lab/22-aop/src/main/java/rewards/internal/monitor/jamon/JamonMonitor.java new file mode 100644 index 0000000..4c40cce --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/monitor/jamon/JamonMonitor.java @@ -0,0 +1,63 @@ +package rewards.internal.monitor.jamon; + +import rewards.internal.monitor.Monitor; +import rewards.internal.monitor.MonitorStatistics; + +public class JamonMonitor implements Monitor, MonitorStatistics { + + private final com.jamonapi.Monitor monitor; + + public JamonMonitor(com.jamonapi.Monitor monitor) { + this.monitor = monitor; + } + + public Monitor start() { + monitor.start(); + return this; + } + + public Monitor stop() { + monitor.stop(); + return this; + } + + public String getName() { + return monitor.getLabel(); + } + + public long getCallCount() { + return (long) monitor.getHits(); + } + + public long getAverageCallTime() { + return (long) monitor.getAvg(); + } + + public long getLastCallTime() { + return (long) monitor.getLastValue(); + } + + public long getMaximumCallTime() { + return (long) monitor.getMax(); + } + + public long getMinimumCallTime() { + return (long) monitor.getMin(); + } + + public long getTotalCallTime() { + return (long) monitor.getTotal(); + } + + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(monitor.getLabel()).append(": "); + sb.append("Last=").append(monitor.getLastValue()).append(", "); + sb.append("Calls=").append(monitor.getHits()).append(", "); + sb.append("Avg=").append(monitor.getAvg()).append(", "); + sb.append("Total=").append(monitor.getTotal()).append(", "); + sb.append("Min=").append(monitor.getMin()).append(", "); + sb.append("Max=").append(monitor.getMax()); + return sb.toString(); + } +} diff --git a/lab/22-aop/src/main/java/rewards/internal/monitor/jamon/JamonMonitorFactory.java b/lab/22-aop/src/main/java/rewards/internal/monitor/jamon/JamonMonitorFactory.java new file mode 100644 index 0000000..fbc6690 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/monitor/jamon/JamonMonitorFactory.java @@ -0,0 +1,59 @@ +package rewards.internal.monitor.jamon; + +import java.util.Date; + +import rewards.internal.monitor.GlobalMonitorStatistics; +import rewards.internal.monitor.Monitor; +import rewards.internal.monitor.MonitorFactory; + +import com.jamonapi.MonitorComposite; + +public class JamonMonitorFactory implements MonitorFactory, GlobalMonitorStatistics { + + private final com.jamonapi.MonitorFactoryInterface monitorFactory = com.jamonapi.MonitorFactory.getFactory(); + + public Monitor start(String name) { + return new JamonMonitor(monitorFactory.start(name)); + } + + public long getCallsCount() { + return (long) getMonitors().getHits(); + } + + public long getTotalCallTime() { + return (long) getMonitors().getTotal(); + } + + public Date getLastAccessTime() { + return getMonitors().getLastAccess(); + } + + public MonitorComposite getMonitors() { + return monitorFactory.getRootMonitor(); + } + + public long averageCallTime(String methodName) { + return (long) monitorFactory.getMonitor(methodName, "ms.").getAvg(); + } + + public long callCount(String methodName) { + return (long) monitorFactory.getMonitor(methodName, "ms.").getHits(); + } + + public long lastCallTime(String methodName) { + return (long) monitorFactory.getMonitor(methodName, "ms.").getLastValue(); + } + + public long maximumCallTime(String methodName) { + return (long) monitorFactory.getMonitor(methodName, "ms.").getMax(); + } + + public long minimumCallTime(String methodName) { + return (long) monitorFactory.getMonitor(methodName, "ms.").getMin(); + } + + public long totalCallTime(String methodName) { + return (long) monitorFactory.getMonitor(methodName, "ms.").getTotal(); + } + +} diff --git a/lab/22-aop/src/main/java/rewards/internal/package.html b/lab/22-aop/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/22-aop/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java b/lab/22-aop/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java new file mode 100644 index 0000000..b7d6d74 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java @@ -0,0 +1,20 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * Determines if benefit is available for an account for dining. + * + * A value object. A strategy. Scoped by the Resturant aggregate. + */ +public interface BenefitAvailabilityPolicy { + + /** + * Calculates if an account is eligible to receive benefits for a dining. + * @param account the account of the member who dined + * @param dining the dining event + * @return benefit availability status + */ + boolean isBenefitAvailableFor(Account account, Dining dining); +} diff --git a/lab/22-aop/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java b/lab/22-aop/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java new file mode 100644 index 0000000..6ba9720 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java @@ -0,0 +1,159 @@ +package rewards.internal.restaurant; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.springframework.dao.EmptyResultDataAccessException; +import rewards.internal.exception.RewardDataAccessException; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.Percentage; + +/** + * Loads restaurants from a data source using the JDBC API. + */ +public class JdbcRestaurantRepository implements RestaurantRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to load restaurants. + * + * @param dataSource the data source + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + String sql = "select MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY from T_RESTAURANT where MERCHANT_NUMBER = ?"; + Restaurant restaurant = null; + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + try { + conn = dataSource.getConnection(); + ps = conn.prepareStatement(sql); + ps.setString(1, merchantNumber); + rs = ps.executeQuery(); + advanceToNextRow(rs); + restaurant = mapRestaurant(rs); + } catch (SQLException e) { + throw new RewardDataAccessException("SQL exception occurred finding by merchant number", e); + } finally { + if (rs != null) { + try { + // Close to prevent database cursor exhaustion + rs.close(); + } catch (SQLException ex) { + } + } + if (ps != null) { + try { + // Close to prevent database cursor exhaustion + ps.close(); + } catch (SQLException ex) { + } + } + if (conn != null) { + try { + // Close to prevent database connection exhaustion + conn.close(); + } catch (SQLException ex) { + } + } + } + return restaurant; + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + * + * @param rs the result set with its cursor positioned at the current row + */ + private Restaurant mapRestaurant(ResultSet rs) throws SQLException { + // get the row column data + String name = rs.getString("NAME"); + String number = rs.getString("MERCHANT_NUMBER"); + Percentage benefitPercentage = Percentage.valueOf(rs.getString("BENEFIT_PERCENTAGE")); + // map to the object + Restaurant restaurant = new Restaurant(number, name); + restaurant.setBenefitPercentage(benefitPercentage); + restaurant.setBenefitAvailabilityPolicy(mapBenefitAvailabilityPolicy(rs)); + return restaurant; + } + + /** + * Advances a ResultSet to the next row and throws an exception if there are no rows. + * @param rs the ResultSet to advance + * @throws EmptyResultDataAccessException if there is no next row + * @throws SQLException + */ + private void advanceToNextRow(ResultSet rs) throws EmptyResultDataAccessException, SQLException { + if (!rs.next()) { + throw new EmptyResultDataAccessException(1); + } + } + + /** + * Helper method that maps benefit availability policy data in the ResultSet to a fully-configured + * {@link BenefitAvailabilityPolicy} object. The key column is 'BENEFIT_AVAILABILITY_POLICY', which is a + * discriminator column containing a string code that identifies the type of policy. Currently supported types are: + * 'A' for 'always available' and 'N' for 'never available'. + * + *

+ * More types could be added easily by enhancing this method. For example, 'W' for 'Weekdays only' or 'M' for 'Max + * Rewards per Month'. Some of these types might require additional database column values to be configured, for + * example a 'MAX_REWARDS_PER_MONTH' data column. + * + * @param rs the result set used to map the policy object from database column values + * @return the matching benefit availability policy + * @throws IllegalArgumentException if the mapping could not be performed + */ + private BenefitAvailabilityPolicy mapBenefitAvailabilityPolicy(ResultSet rs) throws SQLException { + String policyCode = rs.getString("BENEFIT_AVAILABILITY_POLICY"); + if ("A".equals(policyCode)) { + return AlwaysAvailable.INSTANCE; + } else if ("N".equals(policyCode)) { + return NeverAvailable.INSTANCE; + } else { + throw new IllegalArgumentException("Not a supported policy code " + policyCode); + } + } + + /** + * Returns true indicating benefit is always available. + */ + static class AlwaysAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new AlwaysAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + + public String toString() { + return "alwaysAvailable"; + } + } + + /** + * Returns false indicating benefit is never available. + */ + static class NeverAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new NeverAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return false; + } + + public String toString() { + return "neverAvailable"; + } + } +} \ No newline at end of file diff --git a/lab/22-aop/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/22-aop/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..2e73e57 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,102 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on an availability policy and a + * benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + private BenefitAvailabilityPolicy benefitAvailabilityPolicy; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Sets the policy that determines if a dining by an account at this restaurant is eligible for benefit. + * @param benefitAvailabilityPolicy the benefit availability policy + */ + public void setBenefitAvailabilityPolicy(BenefitAvailabilityPolicy benefitAvailabilityPolicy) { + this.benefitAvailabilityPolicy = benefitAvailabilityPolicy; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Returns this restaurant's benefit availability policy. + */ + public BenefitAvailabilityPolicy getBenefitAvailabilityPolicy() { + return benefitAvailabilityPolicy; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + if (benefitAvailabilityPolicy.isBenefitAvailableFor(account, dining)) { + return dining.getAmount().multiplyBy(benefitPercentage); + } else { + return MonetaryAmount.zero(); + } + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage + + ", benefitAvailabilityPolicy = " + benefitAvailabilityPolicy; + } +} \ No newline at end of file diff --git a/lab/22-aop/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/22-aop/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/22-aop/src/main/java/rewards/internal/restaurant/package.html b/lab/22-aop/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/22-aop/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/22-aop/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..69f2552 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,57 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import javax.sql.DataSource; +import java.sql.*; + +/** + * JDBC implementation of a reward repository that records the result of a reward transaction by inserting a reward + * confirmation record. + */ +public class JdbcRewardRepository implements RewardRepository { + + private DataSource dataSource; + + /** + * Sets the data source this repository will use to insert rewards. + * @param dataSource the data source + */ + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public RewardConfirmation updateReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + String confirmationNumber = nextConfirmationNumber(); + ps.setString(1, confirmationNumber); + ps.setBigDecimal(2, contribution.getAmount().asBigDecimal()); + ps.setDate(3, new Date(SimpleDate.today().inMilliseconds())); + ps.setString(4, contribution.getAccountNumber()); + ps.setString(5, dining.getMerchantNumber()); + ps.setDate(6, new Date(dining.getDate().inMilliseconds())); + ps.setBigDecimal(7, dining.getAmount().asBigDecimal()); + ps.execute(); + return new RewardConfirmation(confirmationNumber, contribution); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred inserting reward record", e); + } + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getString(1); + } catch (SQLException e) { + throw new RuntimeException("SQL exception getting next confirmation number", e); + } + } +} \ No newline at end of file diff --git a/lab/22-aop/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/22-aop/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..5b7d4df --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation updateReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/22-aop/src/main/java/rewards/internal/reward/package.html b/lab/22-aop/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/22-aop/src/main/java/rewards/package.html b/lab/22-aop/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/22-aop/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/22-aop/src/test/java/rewards/CaptureSystemOutput.java b/lab/22-aop/src/test/java/rewards/CaptureSystemOutput.java new file mode 100644 index 0000000..c2c169c --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/CaptureSystemOutput.java @@ -0,0 +1,244 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package rewards; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.util.ArrayList; +import java.util.List; + +import org.hamcrest.Matcher; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ExtensionContext.Namespace; +import org.junit.jupiter.api.extension.ExtensionContext.Store; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolver; +import org.junit.platform.commons.support.ReflectionSupport; + +/** + * {@code @CaptureSystemOutput} is a JUnit JUpiter extension for capturing + * output to {@code System.out} and {@code System.err} with expectations + * supported via Hamcrest matchers. + * + *

Example Usage

+ * + *
+ * {@literal @}Test
+ * {@literal @}CaptureSystemOutput
+ * void systemOut(OutputCapture outputCapture) {
+ *     outputCapture.expect(containsString("System.out!"));
+ *
+ *     System.out.println("Printed to System.out!");
+ * }
+ * 
+ * {@literal @}Test
+ * {@literal @}CaptureSystemOutput
+ * void systemErr(OutputCapture outputCapture) {
+ *     outputCapture.expect(containsString("System.err!"));
+ *
+ *     System.err.println("Printed to System.err!");
+ * }
+ * 
+ * + *

Based on code from Spring Boot's + * OutputCapture + * rule for JUnit 4 by Phillip Webb and Andy Wilkinson. + * + * @author Sam Brannen + * @author Phillip Webb + * @author Andy Wilkinson + */ +@Target({ TYPE, METHOD }) +@Retention(RUNTIME) +@ExtendWith(CaptureSystemOutput.Extension.class) +public @interface CaptureSystemOutput { + + class Extension implements BeforeEachCallback, AfterEachCallback, ParameterResolver { + + @Override + public void beforeEach(ExtensionContext context) { + getOutputCapture(context).captureOutput(); + } + + @Override + public void afterEach(ExtensionContext context) { + OutputCapture outputCapture = getOutputCapture(context); + try { + if (!outputCapture.matchers.isEmpty()) { + String output = outputCapture.toString(); + assertThat(output, allOf(outputCapture.matchers)); + } + } + finally { + outputCapture.releaseOutput(); + } + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + boolean isTestMethodLevel = extensionContext.getTestMethod().isPresent(); + boolean isOutputCapture = parameterContext.getParameter().getType() == OutputCapture.class; + return isTestMethodLevel && isOutputCapture; + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + return getOutputCapture(extensionContext); + } + + private OutputCapture getOutputCapture(ExtensionContext context) { + return getOrComputeIfAbsent(getStore(context), OutputCapture.class); + } + + private V getOrComputeIfAbsent(Store store, Class type) { + return store.getOrComputeIfAbsent(type, ReflectionSupport::newInstance, type); + } + + private Store getStore(ExtensionContext context) { + return context.getStore(Namespace.create(getClass(), context.getRequiredTestMethod())); + } + + } + + /** + * {@code OutputCapture} captures output to {@code System.out} and {@code System.err}. + * + *

To obtain an instance of {@code OutputCapture}, declare a parameter of type + * {@code OutputCapture} in a JUnit Jupiter {@code @Test}, {@code @BeforeEach}, + * or {@code @AfterEach} method. + * + *

{@linkplain #expect Expectations} are supported via Hamcrest matchers. + * + *

To obtain all output to {@code System.out} and {@code System.err}, simply + * invoke {@link #toString()}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Sam Brannen + */ + static class OutputCapture { + + private final List> matchers = new ArrayList<>(); + + private CaptureOutputStream captureOut; + + private CaptureOutputStream captureErr; + + private ByteArrayOutputStream copy; + + void captureOutput() { + this.copy = new ByteArrayOutputStream(); + this.captureOut = new CaptureOutputStream(System.out, this.copy); + this.captureErr = new CaptureOutputStream(System.err, this.copy); + System.setOut(new PrintStream(this.captureOut)); + System.setErr(new PrintStream(this.captureErr)); + } + + void releaseOutput() { + System.setOut(this.captureOut.getOriginal()); + System.setErr(this.captureErr.getOriginal()); + this.copy = null; + } + + private void flush() { + try { + this.captureOut.flush(); + this.captureErr.flush(); + } + catch (IOException ex) { + // ignore + } + } + + /** + * Verify that the captured output is matched by the supplied {@code matcher}. + * + *

Verification is performed after the test method has executed. + * + * @param matcher the matcher + */ + public void expect(Matcher matcher) { + this.matchers.add(matcher); + } + + /** + * Return all captured output to {@code System.out} and {@code System.err} + * as a single string. + */ + @Override + public String toString() { + flush(); + return this.copy.toString(); + } + + private static class CaptureOutputStream extends OutputStream { + + private final PrintStream original; + + private final OutputStream copy; + + CaptureOutputStream(PrintStream original, OutputStream copy) { + this.original = original; + this.copy = copy; + } + + PrintStream getOriginal() { + return this.original; + } + + @Override + public void write(int b) throws IOException { + this.copy.write(b); + this.original.write(b); + this.original.flush(); + } + + @Override + public void write(byte[] b) throws IOException { + write(b, 0, b.length); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + this.copy.write(b, off, len); + this.original.write(b, off, len); + } + + @Override + public void flush() throws IOException { + this.copy.flush(); + this.original.flush(); + } + + } + + } + +} diff --git a/lab/22-aop/src/test/java/rewards/DBExceptionHandlingAspectTests.java b/lab/22-aop/src/test/java/rewards/DBExceptionHandlingAspectTests.java new file mode 100644 index 0000000..278e429 --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/DBExceptionHandlingAspectTests.java @@ -0,0 +1,48 @@ +package rewards; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import rewards.CaptureSystemOutput.OutputCapture; +import rewards.internal.account.AccountRepository; +import rewards.internal.aspects.DBExceptionHandlingAspect; +import rewards.internal.exception.RewardDataAccessException; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = { DbExceptionTestConfig.class }) +public class DBExceptionHandlingAspectTests { + + @Autowired + AccountRepository repository; + + @Test + @CaptureSystemOutput + public void testReportException(OutputCapture capture) { + + // The repository.findByCreditCard(..) method below will + // result in an exception because we are using empty database + // set by DbExceptionTestConfig configuration class + // used by @ContextConfiguration annotation above. + assertThrows(RewardDataAccessException.class, () -> { + repository.findByCreditCard("1234123412341234"); + }); + + // TODO-12: (Optional) Validate our AOP is working. + // + // - An error message should now be logged to the console as a warning + // - Save all your work and run this test - it should pass with a warning + // message on the console AND the console output assertion (below) + // should succeed. + + if (TestConstants.CHECK_CONSOLE_OUTPUT) { + assertThat(capture.toString(), containsString(DBExceptionHandlingAspect.EMAIL_FAILURE_MSG)); + } + } +} \ No newline at end of file diff --git a/lab/22-aop/src/test/java/rewards/DbExceptionTestConfig.java b/lab/22-aop/src/test/java/rewards/DbExceptionTestConfig.java new file mode 100644 index 0000000..33b5afd --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/DbExceptionTestConfig.java @@ -0,0 +1,31 @@ +package rewards; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import config.AspectsConfig; +import config.RewardsConfig; + + +@Configuration +@Import({RewardsConfig.class,AspectsConfig.class}) +public class DbExceptionTestConfig { + + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ + @Bean + public DataSource dataSource(){ + return + (new EmbeddedDatabaseBuilder()).setName("rewards-dbexception") + // No scripts added. This will cause an exception. + .build(); + } + +} diff --git a/lab/22-aop/src/test/java/rewards/LoggingAspectTests.java b/lab/22-aop/src/test/java/rewards/LoggingAspectTests.java new file mode 100644 index 0000000..9a278e0 --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/LoggingAspectTests.java @@ -0,0 +1,34 @@ +package rewards; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import rewards.CaptureSystemOutput.OutputCapture; +import rewards.internal.account.AccountRepository; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = { SystemTestConfig.class }) +public class LoggingAspectTests { + + @Autowired + AccountRepository repository; + + @Test + @CaptureSystemOutput + public void testLogger(OutputCapture capture) { + repository.findByCreditCard("1234123412341234"); + + if (TestConstants.CHECK_CONSOLE_OUTPUT) { + // AOP VERIFICATION + // LoggingAspect should have output an INFO message to console + String consoleOutput = capture.toString(); + assertTrue(consoleOutput.startsWith("INFO")); + assertTrue(consoleOutput.contains("rewards.internal.aspects.LoggingAspect")); + } + } +} diff --git a/lab/22-aop/src/test/java/rewards/RewardNetworkTests.java b/lab/22-aop/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..c5ff5f8 --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,113 @@ +package rewards; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import rewards.CaptureSystemOutput.OutputCapture; +import rewards.internal.aspects.LoggingAspect; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A system test that verifies the components of the RewardNetwork application + * work together to reward for dining successfully. Uses Spring to bootstrap the + * application for use in a test environment. + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes={SystemTestConfig.class}) +public class RewardNetworkTests { + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + @Test + @CaptureSystemOutput + public void testRewardForDining(OutputCapture capture) { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + + // TODO-06: Run this test. It should pass AND you should see TWO lines of + // log output from the LoggingAspect on the console + int expectedMatches = 2; + checkConsoleOutput(capture, expectedMatches); + + // TODO-09: Save all your work, and change the expected matches value above from 2 to 4. + // Rerun the RewardNetworkTests. It should pass, and you should now see FOUR lines of + // console output from the LoggingAspect. + } + + /** + * Not only must the code run, but the LoggingAspect should generate logging + * output to the console. + */ + private void checkConsoleOutput(OutputCapture capture, int expectedMatches) { + // Don't run these checks until we are ready + if (!TestConstants.CHECK_CONSOLE_OUTPUT) + return; + + // AOP VERIFICATION + // Expecting 4 lines of output from the LoggingAspect to console + String[] consoleOutput = capture.toString().split("\n"); + int matches = 0; + + for (String line : consoleOutput) { + if (line.contains("rewards.internal.aspects.LoggingAspect")) { + if (line.contains(LoggingAspect.BEFORE)) { + if (line.contains("JdbcAccountRepository") && line.contains("findByCreditCard")) + // Before aspect invoked for + // JdbcAccountRepository.findByCreditCard + matches++; + else if (line.contains("JdbcRestaurantRepository") && line.contains("findByMerchantNumber")) + // Before aspect invoked for + // JdbcRestaurantRepository.findByMerchantNumber + matches++; + } else if (line.contains(LoggingAspect.AROUND)) { + if (line.contains("AccountRepository") && line.contains("updateBeneficiaries")) + // Around aspect invoked for + // AccountRepository.updateBeneficiaries + matches++; + else if (line.contains("Around") && line.contains("RewardRepository") + && line.contains("updateReward")) + // Around aspect invoked for + // RewardRepository.updateReward + matches++; + } + } + } + + assertEquals(expectedMatches, matches); + } + +} \ No newline at end of file diff --git a/lab/22-aop/src/test/java/rewards/SystemTestConfig.java b/lab/22-aop/src/test/java/rewards/SystemTestConfig.java new file mode 100644 index 0000000..ea44b48 --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/SystemTestConfig.java @@ -0,0 +1,36 @@ +package rewards; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import config.RewardsConfig; + + +/** + * TODO-05: Make this configuration include the aspect configuration. + * Save all your work, run the LoggingAspectTests. It should pass, + * and you should see one line of LoggingAspect output in the console. + */ +@Configuration +@Import({RewardsConfig.class}) +public class SystemTestConfig { + + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ + @Bean + public DataSource dataSource(){ + return + (new EmbeddedDatabaseBuilder()) + .addScript("classpath:rewards/testdb/schema.sql") + .addScript("classpath:rewards/testdb/data.sql") + .build(); + } + +} diff --git a/lab/22-aop/src/test/java/rewards/TestConstants.java b/lab/22-aop/src/test/java/rewards/TestConstants.java new file mode 100644 index 0000000..c146c31 --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/TestConstants.java @@ -0,0 +1,14 @@ +package rewards; + +public class TestConstants { + + // TODO-00: In this lab, you are going to exercise the following: + // - Creating aspect using Spring AOP + // - Writing pointcut expressions + // - Using various types of advices + // + // TODO-01: Enable checking of console output in our Tests. + // - Change the value below to true + + public static final boolean CHECK_CONSOLE_OUTPUT = false; +} diff --git a/lab/22-aop/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/22-aop/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..98b7353 --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,72 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/22-aop/src/test/java/rewards/internal/StubAccountRepository.java b/lab/22-aop/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..b926be2 --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,43 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new EmptyResultDataAccessException(1); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/22-aop/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/22-aop/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..418516d --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,53 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.Dining; +import rewards.internal.account.Account; +import rewards.internal.restaurant.BenefitAvailabilityPolicy; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new AlwaysReturnsTrue()); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } + + /** + * A simple "dummy" benefit availability policy that always returns true. Only useful for testing--a real + * availability policy might consider many factors such as the day of week of the dining, or the account's reward + * history for the current month. + */ + private static class AlwaysReturnsTrue implements BenefitAvailabilityPolicy { + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + } +} \ No newline at end of file diff --git a/lab/22-aop/src/test/java/rewards/internal/StubRewardRepository.java b/lab/22-aop/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..d4871f8 --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import java.util.Random; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation updateReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/22-aop/src/test/java/rewards/internal/account/AccountTests.java b/lab/22-aop/src/test/java/rewards/internal/account/AccountTests.java new file mode 100644 index 0000000..4075654 --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/internal/account/AccountTests.java @@ -0,0 +1,57 @@ +package rewards.internal.account; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for the Account class that verify Account behavior works in isolation. + */ +public class AccountTests { + + private final Account account = new Account("1", "Keith and Keri Donald"); + + @Test + public void accountIsValid() { + // setup account with a valid set of beneficiaries to prepare for testing + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + assertTrue(account.isValid()); + } + + @Test + public void accountIsInvalidWithNoBeneficiaries() { + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreOver100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreUnder100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("25%")); + assertFalse(account.isValid()); + } + + @Test + public void makeContribution() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("100.00")); + assertEquals(contribution.getAmount(), MonetaryAmount.valueOf("100.00")); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/22-aop/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java b/lab/22-aop/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java new file mode 100644 index 0000000..4326571 --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java @@ -0,0 +1,96 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC account repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcAccountRepositoryTests { + + private JdbcAccountRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + repository = new JdbcAccountRepository(); + repository.setDataSource(dataSource); + } + + @Test + public void testFindAccountByCreditCard() { + Account account = repository.findByCreditCard("1234123412341234"); + // assert the returned account contains what you expect given the state of the database + assertNotNull(account, "account should never be null"); + assertEquals(Long.valueOf(0), account.getEntityId(), "wrong entity id"); + assertEquals("123456789", account.getNumber(), "wrong account number"); + assertEquals("Keith and Keri Donald", account.getName(), "wrong name"); + assertEquals(2, account.getBeneficiaries().size(), "wrong beneficiary collection size"); + + Beneficiary b1 = account.getBeneficiary("Annabelle"); + assertNotNull(b1, "Annabelle should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b1.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b1.getAllocationPercentage(), "wrong allocation percentage"); + + Beneficiary b2 = account.getBeneficiary("Corgan"); + assertNotNull(b2, "Corgan should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b2.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b2.getAllocationPercentage(), "wrong allocation percentage"); + } + + @Test + public void testFindAccountByCreditCardNoAccount() { + assertThrows(EmptyResultDataAccessException.class, () -> { + repository.findByCreditCard("bogus"); + }); + } + + @Test + public void testUpdateBeneficiaries() throws SQLException { + Account account = repository.findByCreditCard("1234123412341234"); + account.makeContribution(MonetaryAmount.valueOf("8.00")); + repository.updateBeneficiaries(account); + verifyBeneficiaryTableUpdated(); + } + + private void verifyBeneficiaryTableUpdated() throws SQLException { + String sql = "select SAVINGS from T_ACCOUNT_BENEFICIARY where NAME = ? and ACCOUNT_ID = ?"; + PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql); + + // assert Annabelle has $4.00 savings now + stmt.setString(1, "Annabelle"); + stmt.setLong(2, 0L); + ResultSet rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + + // assert Corgan has $4.00 savings now + stmt.setString(1, "Corgan"); + stmt.setLong(2, 0L); + rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/22-aop/src/test/java/rewards/internal/aspects/RepositoryPerformanceLogTest.java b/lab/22-aop/src/test/java/rewards/internal/aspects/RepositoryPerformanceLogTest.java new file mode 100644 index 0000000..bd4beab --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/internal/aspects/RepositoryPerformanceLogTest.java @@ -0,0 +1,42 @@ +package rewards.internal.aspects; + +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.Signature; +import org.aspectj.lang.annotation.Aspect; +import org.easymock.EasyMock; +import org.junit.jupiter.api.Test; + +import rewards.internal.monitor.jamon.JamonMonitorFactory; + +/** + * Unit test to test the behavior of the RepositoryPerformanceMonitor aspect in isolation. + */ +public class RepositoryPerformanceLogTest { + + @Test + public void testMonitor() throws Throwable { + JamonMonitorFactory monitorFactory = new JamonMonitorFactory(); + LoggingAspect performanceMonitor = new LoggingAspect(monitorFactory); + Signature signature = EasyMock.createMock(Signature.class); + ProceedingJoinPoint targetMethod = EasyMock.createMock(ProceedingJoinPoint.class); + + expect(targetMethod.getSignature()).andReturn(signature); + expect(signature.getDeclaringType()).andReturn(Object.class); + expect(signature.getName()).andReturn("hashCode"); + expect(targetMethod.proceed()).andReturn(new Object()); + + replay(signature, targetMethod); + performanceMonitor.monitor(targetMethod); + + // This check only makes sense once we have configured LoggingAspect to + // be an @Aspect class (and marked monitor with @Around) + if (performanceMonitor.getClass().getAnnotation(Aspect.class) != null) { + verify(signature, targetMethod); + } + } + +} diff --git a/lab/22-aop/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java b/lab/22-aop/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java new file mode 100644 index 0000000..bfe24ba --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java @@ -0,0 +1,52 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC restaurant repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRestaurantRepositoryTests { + + private JdbcRestaurantRepository repository; + + @BeforeEach + public void setUp() { + repository = new JdbcRestaurantRepository(); + repository.setDataSource(createTestDataSource()); + } + + @Test + public void testFindRestaurantByMerchantNumber() { + Restaurant restaurant = repository.findByMerchantNumber("1234567890"); + assertNotNull(restaurant, "the restaurant should never be null"); + assertEquals("1234567890", restaurant.getNumber(), "the merchant number is wrong"); + assertEquals("AppleBees", restaurant.getName(), "the name is wrong"); + assertEquals(Percentage.valueOf("8%"), restaurant.getBenefitPercentage(), "the benefitPercentage is wrong"); + assertEquals(JdbcRestaurantRepository.AlwaysAvailable.INSTANCE, + restaurant.getBenefitAvailabilityPolicy(), "the benefit availability policy is wrong"); + } + + @Test + public void testFindRestaurantByBogusMerchantNumber() { + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("bogus"); + }); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/22-aop/src/test/java/rewards/internal/restaurant/RestaurantTests.java b/lab/22-aop/src/test/java/rewards/internal/restaurant/RestaurantTests.java new file mode 100644 index 0000000..93c2496 --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/internal/restaurant/RestaurantTests.java @@ -0,0 +1,71 @@ +package rewards.internal.restaurant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for exercising the behavior of the Restaurant aggregate entity. A restaurant calculates a benefit to award + * to an account for dining based on an availability policy and benefit percentage. + */ +public class RestaurantTests { + + private Restaurant restaurant; + + private Account account; + + private Dining dining; + + @BeforeEach + public void setUp() { + // configure the restaurant, the object being tested + restaurant = new Restaurant("1234567890", "AppleBee's"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(true)); + // configure supporting objects needed by the restaurant + account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle"); + dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + } + + @Test + public void testCalcuateBenefitFor() { + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert 8.00 eligible for reward + assertEquals(MonetaryAmount.valueOf("8.00"), benefit); + } + + @Test + public void testNoBenefitAvailable() { + // configure stub that always returns false + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(false)); + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert zero eligible for reward + assertEquals(MonetaryAmount.valueOf("0.00"), benefit); + } + + /** + * A simple "dummy" benefit availability policy containing a single flag used to determine if benefit is available. + * Only useful for testing--a real availability policy might consider many factors such as the day of week of the + * dining, or the account's reward history for the current month. + */ + private static class StubBenefitAvailibilityPolicy implements BenefitAvailabilityPolicy { + + private final boolean isBenefitAvailable; + + public StubBenefitAvailibilityPolicy(boolean isBenefitAvailable) { + this.isBenefitAvailable = isBenefitAvailable; + } + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return isBenefitAvailable; + } + } +} \ No newline at end of file diff --git a/lab/22-aop/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java b/lab/22-aop/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java new file mode 100644 index 0000000..fbb71e5 --- /dev/null +++ b/lab/22-aop/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java @@ -0,0 +1,80 @@ +package rewards.internal.reward; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.Account; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Tests the JDBC reward repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRewardRepositoryTests { + + private JdbcRewardRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + repository = new JdbcRewardRepository(); + dataSource = createTestDataSource(); + repository.setDataSource(dataSource); + } + + @Test public void createReward() throws SQLException { + Dining dining = Dining.createDining("100.00", "1234123412341234", "0123456789"); + + Account account = new Account("1", "Keith and Keri Donald"); + account.setEntityId(0L); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("8.00")); + RewardConfirmation confirmation = repository.updateReward(contribution, dining); + assertNotNull(confirmation, "confirmation should not be null"); + assertNotNull(confirmation.getConfirmationNumber(), "confirmation number should not be null"); + assertEquals(contribution, confirmation.getAccountContribution(), "wrong contribution object"); + verifyRewardInserted(confirmation, dining); + } + + private void verifyRewardInserted(RewardConfirmation confirmation, Dining dining) throws SQLException { + assertEquals(1, getRewardCount()); + Statement stmt = dataSource.getConnection().createStatement(); + ResultSet rs = stmt.executeQuery("select REWARD_AMOUNT from T_REWARD where CONFIRMATION_NUMBER = '" + + confirmation.getConfirmationNumber() + "'"); + rs.next(); + assertEquals(confirmation.getAccountContribution().getAmount(), MonetaryAmount.valueOf(rs.getString(1))); + } + + private int getRewardCount() throws SQLException { + Statement stmt = dataSource.getConnection().createStatement(); + ResultSet rs = stmt.executeQuery("select count(*) from T_REWARD"); + rs.next(); + return rs.getInt(1); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/24-test-solution/build.gradle b/lab/24-test-solution/build.gradle new file mode 100644 index 0000000..af9f0d4 --- /dev/null +++ b/lab/24-test-solution/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation project(':00-rewards-common') +} diff --git a/lab/24-test-solution/jndi/empty.file b/lab/24-test-solution/jndi/empty.file new file mode 100644 index 0000000..e69de29 diff --git a/lab/24-test-solution/pom.xml b/lab/24-test-solution/pom.xml new file mode 100644 index 0000000..ae66d08 --- /dev/null +++ b/lab/24-test-solution/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + 24-test-solution + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + com.github.h-thurow + simple-jndi + 0.24.0 + test + + + diff --git a/lab/24-test-solution/src/main/java/config/RewardsConfig.java b/lab/24-test-solution/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..00cd3e0 --- /dev/null +++ b/lab/24-test-solution/src/main/java/config/RewardsConfig.java @@ -0,0 +1,10 @@ +package config; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan("rewards") +public class RewardsConfig { + +} diff --git a/lab/24-test-solution/src/main/java/rewards/AccountContribution.java b/lab/24-test-solution/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/main/java/rewards/Dining.java b/lab/24-test-solution/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/main/java/rewards/RewardConfirmation.java b/lab/24-test-solution/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/main/java/rewards/RewardNetwork.java b/lab/24-test-solution/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/24-test-solution/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/24-test-solution/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..d07b40a --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,55 @@ +package rewards.internal; + +import org.springframework.stereotype.Service; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +@Service("rewardNetwork") +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/main/java/rewards/internal/account/Account.java b/lab/24-test-solution/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..51119d4 --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,156 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public Beneficiary getBeneficiary(String beneficiaryName) { + for (Beneficiary b : beneficiaries) { + if (b.getName().equals(beneficiaryName)) + return b; + } + + return null; + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + // we are not using the Percentage class here because + double totalPercentage = 0; + for (Beneficiary b : beneficiaries) { + totalPercentage += b.getAllocationPercentage().asDouble(); + } + return totalPercentage == 1.0; + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. + *

+ * Callers should not attempt to hold on or modify the returned set. This method should only be used transitively; + * for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/main/java/rewards/internal/account/AccountRepository.java b/lab/24-test-solution/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..16c6079 --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/24-test-solution/src/main/java/rewards/internal/account/Beneficiary.java b/lab/24-test-solution/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java b/lab/24-test-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java new file mode 100644 index 0000000..7e4c4ee --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java @@ -0,0 +1,153 @@ +package rewards.internal.account; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Profile; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.stereotype.Repository; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Loads accounts from a data source using the JDBC API. + */ +@Profile("jdbc") +@Repository +public class JdbcAccountRepository implements AccountRepository { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private DataSource dataSource; + + /** + * Constructor logs creation so we know which repository we are using. + */ + public JdbcAccountRepository() { + logger.info("Creating " + getClass().getSimpleName()); + } + + /** + * Sets the data source this repository will use to load accounts. + * + * @param dataSource + * the data source + */ + @Autowired + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Account findByCreditCard(String creditCardNumber) { + String sql = "select a.ID as ID, a.NUMBER as ACCOUNT_NUMBER, a.NAME as ACCOUNT_NAME, c.NUMBER as CREDIT_CARD_NUMBER, b.NAME as BENEFICIARY_NAME, b.ALLOCATION_PERCENTAGE as BENEFICIARY_ALLOCATION_PERCENTAGE, b.SAVINGS as BENEFICIARY_SAVINGS from T_ACCOUNT a, T_ACCOUNT_BENEFICIARY b, T_ACCOUNT_CREDIT_CARD c where ID = b.ACCOUNT_ID and ID = c.ACCOUNT_ID and c.NUMBER = ?"; + Account account = null; + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + try { + conn = dataSource.getConnection(); + ps = conn.prepareStatement(sql); + ps.setString(1, creditCardNumber); + rs = ps.executeQuery(); + account = mapAccount(rs); + } catch (SQLException e) { + throw new RuntimeException( + "SQL exception occurred finding by credit card number", e); + } finally { + if (rs != null) { + try { + // Close to prevent database cursor exhaustion + rs.close(); + } catch (SQLException ex) { + } + } + if (ps != null) { + try { + // Close to prevent database cursor exhaustion + ps.close(); + } catch (SQLException ex) { + } + } + if (conn != null) { + try { + // Close to prevent database connection exhaustion + conn.close(); + } catch (SQLException ex) { + } + } + } + return account; + } + + public void updateBeneficiaries(Account account) { + String sql = "update T_ACCOUNT_BENEFICIARY SET SAVINGS = ? where ACCOUNT_ID = ? and NAME = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + for (Beneficiary beneficiary : account.getBeneficiaries()) { + ps.setBigDecimal(1, beneficiary.getSavings().asBigDecimal()); + ps.setLong(2, account.getEntityId()); + ps.setString(3, beneficiary.getName()); + ps.executeUpdate(); + } + } catch (SQLException e) { + throw new RuntimeException( + "SQL exception occurred updating beneficiary savings", e); + } + } + + /** + * Map the rows returned from the join of T_ACCOUNT and + * T_ACCOUNT_BENEFICIARY to a fully-reconstituted Account aggregate. + * + * @param rs + * the set of rows returned from the query + * @return the mapped Account aggregate + * @throws SQLException + * an exception occurred extracting data from the result set + */ + private Account mapAccount(ResultSet rs) throws SQLException { + Account account = null; + while (rs.next()) { + if (account == null) { + String number = rs.getString("ACCOUNT_NUMBER"); + String name = rs.getString("ACCOUNT_NAME"); + account = new Account(number, name); + // set internal entity identifier (primary key) + account.setEntityId(rs.getLong("ID")); + } + account.restoreBeneficiary(mapBeneficiary(rs)); + } + if (account == null) { + // no rows returned - throw an empty result exception + throw new EmptyResultDataAccessException(1); + } + return account; + } + + /** + * Maps the beneficiary columns in a single row to an AllocatedBeneficiary + * object. + * + * @param rs + * the result set with its cursor positioned at the current row + * @return an allocated beneficiary + * @throws SQLException + * an exception occurred extracting data from the result set + */ + private Beneficiary mapBeneficiary(ResultSet rs) throws SQLException { + String name = rs.getString("BENEFICIARY_NAME"); + MonetaryAmount savings = MonetaryAmount.valueOf(rs + .getString("BENEFICIARY_SAVINGS")); + Percentage allocationPercentage = Percentage.valueOf(rs + .getString("BENEFICIARY_ALLOCATION_PERCENTAGE")); + return new Beneficiary(name, allocationPercentage, savings); + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/main/java/rewards/internal/account/package.html b/lab/24-test-solution/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/24-test-solution/src/main/java/rewards/internal/package.html b/lab/24-test-solution/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/24-test-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java b/lab/24-test-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java new file mode 100644 index 0000000..c626a3a --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java @@ -0,0 +1,122 @@ +package rewards.internal.restaurant; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.Map; + +import javax.sql.DataSource; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Profile; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.stereotype.Repository; + +import common.money.Percentage; + +/** + * Loads restaurants from a data source using the JDBC API. + */ +@Profile("jdbc") +@Repository +public class JdbcRestaurantRepository implements RestaurantRepository { + + private DataSource dataSource; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + /** + * The Restaurant object cache. Cached restaurants are indexed by their merchant numbers. + */ + private Map restaurantCache; + + /** + * Constructor logs creation so we know which repository we are using. + */ + public JdbcRestaurantRepository() { + logger.info("Creating " + getClass().getSimpleName()); + } + + /** + * Sets the data source this repository will use to load restaurants. + * + * @param dataSource the data source + */ + @Autowired + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + return queryRestaurantCache(merchantNumber); + } + + /** + * Helper method that populates the {@link #restaurantCache restaurant object cache} from rows in the T_RESTAURANT + * table. Cached restaurants are indexed by their merchant numbers. This method is called on initialization. + */ + @PostConstruct + void populateRestaurantCache() { + logger.info("Loading restaurant cache"); + restaurantCache = new HashMap<>(); + String sql = "select MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE from T_RESTAURANT"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + Restaurant restaurant = mapRestaurant(rs); + // index the restaurant by its merchant number + restaurantCache.put(restaurant.getNumber(), restaurant); + } + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred finding by merchant number", e); + } + + logger.info("Finished loading restaurant cache"); + } + + /** + * Helper method that simply queries the cache of restaurants. + * + * @param merchantNumber the restaurant's merchant number + * @return the restaurant + * @throws EmptyResultDataAccessException if no restaurant was found with that merchant number + */ + private Restaurant queryRestaurantCache(String merchantNumber) { + Restaurant restaurant = restaurantCache.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } + + /** + * Helper method that clears the cache of restaurants. This method is called on destruction + */ + @PreDestroy + void clearRestaurantCache() { + logger.info("Clearing restaurant cache"); + restaurantCache.clear(); + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + * + * @param rs the result set with its cursor positioned at the current row + */ + private Restaurant mapRestaurant(ResultSet rs) throws SQLException { + // get the row column data + String name = rs.getString("NAME"); + String number = rs.getString("MERCHANT_NUMBER"); + Percentage benefitPercentage = Percentage.valueOf(rs.getString("BENEFIT_PERCENTAGE")); + // map to the object + Restaurant restaurant = new Restaurant(number, name); + restaurant.setBenefitPercentage(benefitPercentage); + return restaurant; + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/24-test-solution/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..6907b79 --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,89 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for + * dining based on a benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * + * @param number + * the restaurant's merchant number + * @param name + * the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Calculate the benefit eligible to this account for dining at this + * restaurant. + * + * @param account + * the account that dined at this restaurant + * @param dining + * a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + return dining.getAmount().multiplyBy(benefitPercentage); + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + + "', benefitPercentage = " + benefitPercentage; + } + +} \ No newline at end of file diff --git a/lab/24-test-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/24-test-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/24-test-solution/src/main/java/rewards/internal/restaurant/package.html b/lab/24-test-solution/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/24-test-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/24-test-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..f46e4bd --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,74 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import javax.sql.DataSource; +import java.sql.*; + +/** + * JDBC implementation of a reward repository that records the result of a reward transaction by inserting a reward + * confirmation record. + */ +@Profile("jdbc") +@Repository +public class JdbcRewardRepository implements RewardRepository { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private DataSource dataSource; + + /** + * Constructor logs creation so we know which repository we are using. + */ + public JdbcRewardRepository() { + logger.info("Creating " + getClass().getSimpleName()); + } + + /** + * Sets the data source this repository will use to insert rewards. + * @param dataSource the data source + */ + @Autowired + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + String confirmationNumber = nextConfirmationNumber(); + ps.setString(1, confirmationNumber); + ps.setBigDecimal(2, contribution.getAmount().asBigDecimal()); + ps.setDate(3, new Date(SimpleDate.today().inMilliseconds())); + ps.setString(4, contribution.getAccountNumber()); + ps.setString(5, dining.getMerchantNumber()); + ps.setDate(6, new Date(dining.getDate().inMilliseconds())); + ps.setBigDecimal(7, dining.getAmount().asBigDecimal()); + ps.execute(); + return new RewardConfirmation(confirmationNumber, contribution); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred inserting reward record", e); + } + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getString(1); + } catch (SQLException e) { + throw new RuntimeException("SQL exception getting next confirmation number", e); + } + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/24-test-solution/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/24-test-solution/src/main/java/rewards/internal/reward/package.html b/lab/24-test-solution/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/24-test-solution/src/main/java/rewards/package.html b/lab/24-test-solution/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/24-test-solution/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/24-test-solution/src/test/java/rewards/DevRewardNetworkTests.java b/lab/24-test-solution/src/test/java/rewards/DevRewardNetworkTests.java new file mode 100644 index 0000000..f56a023 --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/DevRewardNetworkTests.java @@ -0,0 +1,62 @@ +package rewards; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * A system test that verifies the components of the RewardNetwork application + * work together to reward for dining successfully. Uses Spring to bootstrap the + * application for use in a test environment using the development in-memory + * database and JDBC implementations of the repositories. + */ +@SpringJUnitConfig(classes=TestInfrastructureConfig.class) +@ActiveProfiles({ "local", "jdbc" }) +public class DevRewardNetworkTests { + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + @Test + @DisplayName("test if reward computation and distribution works") + public void rewardForDining() { + // create a new dining of 100.00 charged to credit card + // '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", + "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork + .rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation + .getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + // each distribution should be 4.00 (as both have a 50% allocation) + assertAll("distribution of reward", + () -> assertEquals(2, contribution.getDistributions().size()), + () -> assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()), + () -> assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount())); + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/test/java/rewards/LoggingBeanPostProcessor.java b/lab/24-test-solution/src/test/java/rewards/LoggingBeanPostProcessor.java new file mode 100644 index 0000000..d084def --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/LoggingBeanPostProcessor.java @@ -0,0 +1,35 @@ +package rewards; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; + +/** + * A simple bean post-processor that logs each newly created bean it sees. Inner + * beans are ignored. A very easy way to see what beans you have created and + * less verbose than turning on Spring's internal logging. + */ +public class LoggingBeanPostProcessor implements BeanPostProcessor { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) + throws BeansException { + return bean; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) + throws BeansException { + + // Log the names and types of all non inner-beans created + if (!beanName.contains("inner bean")) + logger.info("NEW " + bean.getClass().getSimpleName() + " -> " + + beanName); + + return bean; + } + +} diff --git a/lab/24-test-solution/src/test/java/rewards/ProductionRewardNetworkTests.java b/lab/24-test-solution/src/test/java/rewards/ProductionRewardNetworkTests.java new file mode 100644 index 0000000..673ef44 --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/ProductionRewardNetworkTests.java @@ -0,0 +1,63 @@ +package rewards; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * A system test that verifies the components of the RewardNetwork application + * work together to reward for dining successfully. Uses Spring to bootstrap the + * application for use in a test environment using a JNDI look-up foe the + * dataSource (as would be used in production) and JDBC implementations of the + * repositories. + */ +@SpringJUnitConfig(classes=TestInfrastructureConfig.class) +@ActiveProfiles({ "jndi", "jdbc" }) +public class ProductionRewardNetworkTests { + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + @Test + @DisplayName("test if reward computation and distribution works") + public void rewardForDining() { + // create a new dining of 100.00 charged to credit card + // '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", + "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork + .rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation + .getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + // each distribution should be 4.00 (as both have a 50% allocation) + assertAll("distribution of reward", + () -> assertEquals(2, contribution.getDistributions().size()), + () -> assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()), + () -> assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount())); + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/test/java/rewards/RewardNetworkTests.java b/lab/24-test-solution/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..2387c76 --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,65 @@ +package rewards; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * A system test that verifies the components of the RewardNetwork application + * work together to reward for dining successfully. Uses Spring to bootstrap the + * application for use in a test environment. + */ +@SpringJUnitConfig(classes=TestInfrastructureConfig.class) + +// Uncomment the profile you wish to use +@ActiveProfiles("stub") +//@ActiveProfiles({ "local", "jdbc" }) +//@ActiveProfiles({ "jndi", "jdbc" }) +public class RewardNetworkTests { + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + @Test + @DisplayName("test if reward computation and distribution works") + public void rewardForDining() { + // create a new dining of 100.00 charged to credit card + // '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", + "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork + .rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation + .getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + // each distribution should be 4.00 (as both have a 50% allocation) + assertAll("distribution of reward", + () -> assertEquals(2, contribution.getDistributions().size()), + () -> assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()), + () -> assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount())); + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/test/java/rewards/SimpleJndiHelper.java b/lab/24-test-solution/src/test/java/rewards/SimpleJndiHelper.java new file mode 100644 index 0000000..ba421c1 --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/SimpleJndiHelper.java @@ -0,0 +1,72 @@ +package rewards; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.naming.Context; +import javax.naming.InitialContext; +import javax.naming.NamingException; +import javax.sql.DataSource; + +/** + * This class sets up Simple JNDI, creates an embedded dataSource and registers + * it with JNDI. Normally JNDI is provided by a container such as Tomcat. This + * allows JNDI to be used standalone in a test. + *

+ * We need this to be registered and available before any Spring beans are + * created, or our JNDI lookup will fail. We have therefore made this bean into + * a BeanFactoryPostProcessor - it will be invoked just before the + * first bean is created. + */ +public class SimpleJndiHelper implements BeanFactoryPostProcessor { + + public static final String REWARDS_DB_JNDI_PATH = "java:/comp/env/jdbc/rewards"; + private final Logger logger = LoggerFactory.getLogger(getClass()); + + public void doJndiSetup() { + System.setProperty(Context.INITIAL_CONTEXT_FACTORY, // + "org.osjava.sj.SimpleContextFactory"); + System.setProperty("org.osjava.sj.root", "jndi"); + System.setProperty("org.osjava.jndi.delimiter", "/"); + System.setProperty("org.osjava.sj.jndi.shared", "true"); + + logger.info("Running JDNI setup"); + + try { + InitialContext ic = new InitialContext(); + + // Construct DataSource + DataSource ds = new EmbeddedDatabaseBuilder() // + .addScript("classpath:rewards/testdb/schema.sql") // + .addScript("classpath:rewards/testdb/data.sql") // + .build(); + + // Bind as a JNDI resource + ic.rebind(REWARDS_DB_JNDI_PATH, ds); + logger.info("JNDI Resource '" + REWARDS_DB_JNDI_PATH // + + "' instanceof " + ds.getClass().getSimpleName()); + } catch (NamingException ex) { + logger.error("JNDI setup error", ex); + ex.printStackTrace(); + System.exit(0); + } + + logger.info("JNDI Registrations completed."); + } + + /** + * Using the BeanFactoryPostProcessor as a convenient entry-point to do setup + * before Spring creates any brans. + */ + @Override + public void postProcessBeanFactory( // + ConfigurableListableBeanFactory beanFactory) throws BeansException { + doJndiSetup(); + return; + } + +} diff --git a/lab/24-test-solution/src/test/java/rewards/StubRewardNetworkTests.java b/lab/24-test-solution/src/test/java/rewards/StubRewardNetworkTests.java new file mode 100644 index 0000000..3eb2403 --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/StubRewardNetworkTests.java @@ -0,0 +1,63 @@ +package rewards; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A system test that verifies the components of the RewardNetwork application + * work together to reward for dining successfully. Uses Spring to bootstrap the + * application for use in a test environment using Stub repository classes. + */ +@SpringJUnitConfig(classes=TestInfrastructureConfig.class) +@ActiveProfiles("stub") +public class StubRewardNetworkTests { + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + @Test + public void rewardForDining() { + // create a new dining of 100.00 charged to credit card + // '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", + "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork + .rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation + .getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 + // distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution + .getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution + .getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/test/java/rewards/TestInfrastructureConfig.java b/lab/24-test-solution/src/test/java/rewards/TestInfrastructureConfig.java new file mode 100644 index 0000000..371cfde --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/TestInfrastructureConfig.java @@ -0,0 +1,23 @@ +package rewards; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import config.RewardsConfig; + +@Configuration +@Import({ + TestInfrastructureLocalConfig.class, + TestInfrastructureJndiConfig.class, + RewardsConfig.class }) +public class TestInfrastructureConfig { + + /** + * The bean logging post-processor from the bean lifecycle slides. + */ + @Bean + public static LoggingBeanPostProcessor loggingBean(){ + return new LoggingBeanPostProcessor(); + } +} diff --git a/lab/24-test-solution/src/test/java/rewards/TestInfrastructureJndiConfig.java b/lab/24-test-solution/src/test/java/rewards/TestInfrastructureJndiConfig.java new file mode 100644 index 0000000..465ec8d --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/TestInfrastructureJndiConfig.java @@ -0,0 +1,28 @@ +package rewards; + +import javax.naming.InitialContext; +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@Profile("jndi") +public class TestInfrastructureJndiConfig { + + /** + * Static method because we are defining a Bean post-processor. + */ + @Bean + public static SimpleJndiHelper jndiHelper(){ + return new SimpleJndiHelper(); + } + + @Bean + public DataSource dataSource() throws Exception { + return (DataSource) + (new InitialContext()) + .lookup("java:/comp/env/jdbc/rewards"); + } +} diff --git a/lab/24-test-solution/src/test/java/rewards/TestInfrastructureLocalConfig.java b/lab/24-test-solution/src/test/java/rewards/TestInfrastructureLocalConfig.java new file mode 100644 index 0000000..f8f63d6 --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/TestInfrastructureLocalConfig.java @@ -0,0 +1,26 @@ +package rewards; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +@Configuration +@Profile("local") +public class TestInfrastructureLocalConfig { + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ + @Bean + public DataSource dataSource(){ + return + (new EmbeddedDatabaseBuilder()) + .addScript("classpath:rewards/testdb/schema.sql") + .addScript("classpath:rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/24-test-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/24-test-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..98b7353 --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,72 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/test/java/rewards/internal/StubAccountRepository.java b/lab/24-test-solution/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..ae91e4d --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,58 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.stereotype.Repository; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account + * "Keith and Keri Donald" with two beneficiaries "Annabelle" (50% allocation) + * and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can + * work with this stub and not have to bring in expensive and/or complex + * dependencies such as a Database. Simple unit tests can then verify object + * behavior by considering the state of this stub. + */ +@Profile("stub") +@Repository("accountRepository") +public class StubAccountRepository implements AccountRepository { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final Map accountsByCreditCard = new HashMap<>(); + + /** + * Creates a single test account with two beneficiaries. Also logs creation + * so we know which repository we are using. + */ + public StubAccountRepository() { + logger.info("Creating " + getClass().getSimpleName()); + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new EmptyResultDataAccessException(1); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/24-test-solution/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..04873a6 --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,53 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.stereotype.Repository; + +import common.money.Percentage; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +/** + * A dummy restaurant repository implementation. Has a single restaurant + * "Apple Bees" with a 8% benefit availability percentage that's always + * available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can + * work with this stub and not have to bring in expensive and/or complex + * dependencies such as a Database. Simple unit tests can then verify object + * behavior by considering the state of this stub. + */ +@Profile("stub") +@Repository("restaurantRepository") +public class StubRestaurantRepository implements RestaurantRepository { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + /** + * Creates a single test restaurant with an 8% benefit policy. Also logs + * creation so we know which repository we are using. + */ + public StubRestaurantRepository() { + logger.info("Creating " + getClass().getSimpleName()); + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber + .get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/test/java/rewards/internal/StubRewardRepository.java b/lab/24-test-solution/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..eb51568 --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,39 @@ +package rewards.internal; + +import java.util.Random; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +@Profile("stub") +@Repository("rewardRepository") +public class StubRewardRepository implements RewardRepository { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + /** + * Constructor logs creation so we know which repository we are using. + */ + public StubRewardRepository() { + logger.info("Creating " + getClass().getSimpleName()); + } + + public RewardConfirmation confirmReward(AccountContribution contribution, + Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/test/java/rewards/internal/account/AccountTests.java b/lab/24-test-solution/src/test/java/rewards/internal/account/AccountTests.java new file mode 100644 index 0000000..be9a315 --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/internal/account/AccountTests.java @@ -0,0 +1,69 @@ +package rewards.internal.account; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for the Account class that verify Account behavior works in isolation. + */ +public class AccountTests { + + private final Account account = new Account("1", "Keith and Keri Donald"); + + @Test + public void accountIsValid() { + // setup account with a valid set of beneficiaries to prepare for testing + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + assertTrue(account.isValid()); + } + + @Test + public void accountIsInvalidWithNoBeneficiaries() { + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreOver100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreUnder100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("25%")); + assertFalse(account.isValid()); + } + + @Test + public void makeContribution() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("100.00")); + assertEquals(contribution.getAmount(), MonetaryAmount.valueOf("100.00")); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Corgan").getAmount()); + } + + @Test + public void throwIllegalStateExceptionWhenContributionIsInvalid() { + Throwable exception = assertThrows(IllegalStateException.class, + () -> { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + account.makeContribution(MonetaryAmount.valueOf("100.00")); + }); + assertEquals("Cannot make contributions to this account: it has invalid beneficiary allocations", exception.getMessage()); + } +} \ No newline at end of file diff --git a/lab/24-test-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java b/lab/24-test-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java new file mode 100644 index 0000000..4326571 --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java @@ -0,0 +1,96 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC account repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcAccountRepositoryTests { + + private JdbcAccountRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + repository = new JdbcAccountRepository(); + repository.setDataSource(dataSource); + } + + @Test + public void testFindAccountByCreditCard() { + Account account = repository.findByCreditCard("1234123412341234"); + // assert the returned account contains what you expect given the state of the database + assertNotNull(account, "account should never be null"); + assertEquals(Long.valueOf(0), account.getEntityId(), "wrong entity id"); + assertEquals("123456789", account.getNumber(), "wrong account number"); + assertEquals("Keith and Keri Donald", account.getName(), "wrong name"); + assertEquals(2, account.getBeneficiaries().size(), "wrong beneficiary collection size"); + + Beneficiary b1 = account.getBeneficiary("Annabelle"); + assertNotNull(b1, "Annabelle should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b1.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b1.getAllocationPercentage(), "wrong allocation percentage"); + + Beneficiary b2 = account.getBeneficiary("Corgan"); + assertNotNull(b2, "Corgan should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b2.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b2.getAllocationPercentage(), "wrong allocation percentage"); + } + + @Test + public void testFindAccountByCreditCardNoAccount() { + assertThrows(EmptyResultDataAccessException.class, () -> { + repository.findByCreditCard("bogus"); + }); + } + + @Test + public void testUpdateBeneficiaries() throws SQLException { + Account account = repository.findByCreditCard("1234123412341234"); + account.makeContribution(MonetaryAmount.valueOf("8.00")); + repository.updateBeneficiaries(account); + verifyBeneficiaryTableUpdated(); + } + + private void verifyBeneficiaryTableUpdated() throws SQLException { + String sql = "select SAVINGS from T_ACCOUNT_BENEFICIARY where NAME = ? and ACCOUNT_ID = ?"; + PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql); + + // assert Annabelle has $4.00 savings now + stmt.setString(1, "Annabelle"); + stmt.setLong(2, 0L); + ResultSet rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + + // assert Corgan has $4.00 savings now + stmt.setString(1, "Corgan"); + stmt.setLong(2, 0L); + rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/24-test-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java b/lab/24-test-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java new file mode 100644 index 0000000..f390d72 --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java @@ -0,0 +1,78 @@ +package rewards.internal.restaurant; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import common.money.Percentage; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC restaurant repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRestaurantRepositoryTests { + + private JdbcRestaurantRepository repository; + + @BeforeEach + public void setUp() { + // simulate the Spring bean initialization lifecycle: + + // first, construct the bean + repository = new JdbcRestaurantRepository(); + + // then, inject its dependencies + repository.setDataSource(createTestDataSource()); + + // lastly, initialize the bean + repository.populateRestaurantCache(); + } + + @AfterEach + public void tearDown() { + // simulate the Spring bean destruction lifecycle: + + // destroy the bean + repository.clearRestaurantCache(); + } + + @Test + public void findRestaurantByMerchantNumber() { + Restaurant restaurant = repository.findByMerchantNumber("1234567890"); + assertNotNull(restaurant, "the restaurant should never be null"); + assertEquals("1234567890", restaurant.getNumber(), "the merchant number is wrong"); + assertEquals("AppleBees", restaurant.getName(), "the name is wrong"); + assertEquals(Percentage.valueOf("8%"), restaurant.getBenefitPercentage(), "the benefitPercentage is wrong"); + } + + @Test + public void testFindRestaurantByBogusMerchantNumber() { + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("bogus"); + }); + } + + @Test + public void restaurantCacheClearedAfterDestroy() { + // force early tear down + tearDown(); + + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("1234567890"); + }); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/24-test-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java b/lab/24-test-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java new file mode 100644 index 0000000..b366df4 --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java @@ -0,0 +1,81 @@ +package rewards.internal.reward; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.Account; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Tests the JDBC reward repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRewardRepositoryTests { + + private JdbcRewardRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + repository = new JdbcRewardRepository(); + dataSource = createTestDataSource(); + repository.setDataSource(dataSource); + } + + @Test + public void testCreateReward() throws SQLException { + Dining dining = Dining.createDining("100.00", "1234123412341234", "0123456789"); + + Account account = new Account("1", "Keith and Keri Donald"); + account.setEntityId(0L); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("8.00")); + RewardConfirmation confirmation = repository.confirmReward(contribution, dining); + assertNotNull(confirmation, "confirmation should not be null"); + assertNotNull(confirmation.getConfirmationNumber(), "confirmation number should not be null"); + assertEquals(contribution, confirmation.getAccountContribution(), "wrong contribution object"); + verifyRewardInserted(confirmation, dining); + } + + private void verifyRewardInserted(RewardConfirmation confirmation, Dining dining) throws SQLException { + assertEquals(1, getRewardCount()); + Statement stmt = dataSource.getConnection().createStatement(); + ResultSet rs = stmt.executeQuery("select REWARD_AMOUNT from T_REWARD where CONFIRMATION_NUMBER = '" + + confirmation.getConfirmationNumber() + "'"); + rs.next(); + assertEquals(confirmation.getAccountContribution().getAmount(), MonetaryAmount.valueOf(rs.getString(1))); + } + + private int getRewardCount() throws SQLException { + Statement stmt = dataSource.getConnection().createStatement(); + ResultSet rs = stmt.executeQuery("select count(*) from T_REWARD"); + rs.next(); + return rs.getInt(1); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/24-test-solution/src/test/java/rewards/jndi.properties b/lab/24-test-solution/src/test/java/rewards/jndi.properties new file mode 100644 index 0000000..3bfd943 --- /dev/null +++ b/lab/24-test-solution/src/test/java/rewards/jndi.properties @@ -0,0 +1,4 @@ +java.naming.factory.initial=org.osjava.sj.SimpleContextFactory +org.osjava.sj.root=target/test-classes/config +org.osjava.jndi.delimiter=/ +org.osjava.sj.jndi.shared=true \ No newline at end of file diff --git a/lab/24-test-solution/src/test/resources/jndi/jndi.properties b/lab/24-test-solution/src/test/resources/jndi/jndi.properties new file mode 100644 index 0000000..3bfd943 --- /dev/null +++ b/lab/24-test-solution/src/test/resources/jndi/jndi.properties @@ -0,0 +1,4 @@ +java.naming.factory.initial=org.osjava.sj.SimpleContextFactory +org.osjava.sj.root=target/test-classes/config +org.osjava.jndi.delimiter=/ +org.osjava.sj.jndi.shared=true \ No newline at end of file diff --git a/lab/24-test/build.gradle b/lab/24-test/build.gradle new file mode 100644 index 0000000..af9f0d4 --- /dev/null +++ b/lab/24-test/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation project(':00-rewards-common') +} diff --git a/lab/24-test/jndi/empty.file b/lab/24-test/jndi/empty.file new file mode 100644 index 0000000..e69de29 diff --git a/lab/24-test/pom.xml b/lab/24-test/pom.xml new file mode 100644 index 0000000..60c0a21 --- /dev/null +++ b/lab/24-test/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + 24-test + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + com.github.h-thurow + simple-jndi + 0.24.0 + test + + + diff --git a/lab/24-test/src/main/java/config/RewardsConfig.java b/lab/24-test/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..00cd3e0 --- /dev/null +++ b/lab/24-test/src/main/java/config/RewardsConfig.java @@ -0,0 +1,10 @@ +package config; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan("rewards") +public class RewardsConfig { + +} diff --git a/lab/24-test/src/main/java/rewards/AccountContribution.java b/lab/24-test/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/24-test/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/24-test/src/main/java/rewards/Dining.java b/lab/24-test/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/24-test/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/24-test/src/main/java/rewards/RewardConfirmation.java b/lab/24-test/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/24-test/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/24-test/src/main/java/rewards/RewardNetwork.java b/lab/24-test/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/24-test/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/24-test/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/24-test/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..d07b40a --- /dev/null +++ b/lab/24-test/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,55 @@ +package rewards.internal; + +import org.springframework.stereotype.Service; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +@Service("rewardNetwork") +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/24-test/src/main/java/rewards/internal/account/Account.java b/lab/24-test/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..84463f0 --- /dev/null +++ b/lab/24-test/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,159 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + try { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } catch (IllegalArgumentException e) { + // total would have been over 100% - return invalid + return false; + } + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. Callers should not attempt to hold on or modify the returned set. + * This method should only be used transitively; for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Returns a single account beneficiary. Callers should not attempt to hold on or modify the returned object. This + * method should only be used transitively; for example, called to facilitate reporting or testing. + * @param name the name of the beneficiary e.g "Annabelle" + * @return the beneficiary object + */ + public Beneficiary getBeneficiary(String name) { + for (Beneficiary b : beneficiaries) { + if (b.getName().equals(name)) { + return b; + } + } + throw new IllegalArgumentException("No such beneficiary with name '" + name + "'"); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/24-test/src/main/java/rewards/internal/account/AccountRepository.java b/lab/24-test/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..16c6079 --- /dev/null +++ b/lab/24-test/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/24-test/src/main/java/rewards/internal/account/Beneficiary.java b/lab/24-test/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/24-test/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/24-test/src/main/java/rewards/internal/account/JdbcAccountRepository.java b/lab/24-test/src/main/java/rewards/internal/account/JdbcAccountRepository.java new file mode 100644 index 0000000..c934040 --- /dev/null +++ b/lab/24-test/src/main/java/rewards/internal/account/JdbcAccountRepository.java @@ -0,0 +1,138 @@ +package rewards.internal.account; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.stereotype.Repository; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Loads accounts from a data source using the JDBC API. + */ +@Repository +public class JdbcAccountRepository implements AccountRepository { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private DataSource dataSource; + + /** + * Constructor logs creation so we know which repository we are using. + */ + public JdbcAccountRepository() { + logger.info("Creating " + getClass().getSimpleName()); + } + + /** + * Sets the data source this repository will use to load accounts. + * @param dataSource the data source + */ + @Autowired + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Account findByCreditCard(String creditCardNumber) { + String sql = "select a.ID as ID, a.NUMBER as ACCOUNT_NUMBER, a.NAME as ACCOUNT_NAME, c.NUMBER as CREDIT_CARD_NUMBER, b.NAME as BENEFICIARY_NAME, b.ALLOCATION_PERCENTAGE as BENEFICIARY_ALLOCATION_PERCENTAGE, b.SAVINGS as BENEFICIARY_SAVINGS from T_ACCOUNT a, T_ACCOUNT_BENEFICIARY b, T_ACCOUNT_CREDIT_CARD c where ID = b.ACCOUNT_ID and ID = c.ACCOUNT_ID and c.NUMBER = ?"; + Account account = null; + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + try { + conn = dataSource.getConnection(); + ps = conn.prepareStatement(sql); + ps.setString(1, creditCardNumber); + rs = ps.executeQuery(); + account = mapAccount(rs); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred finding by credit card number", e); + } finally { + if (rs != null) { + try { + // Close to prevent database cursor exhaustion + rs.close(); + } catch (SQLException ex) { + } + } + if (ps != null) { + try { + // Close to prevent database cursor exhaustion + ps.close(); + } catch (SQLException ex) { + } + } + if (conn != null) { + try { + // Close to prevent database connection exhaustion + conn.close(); + } catch (SQLException ex) { + } + } + } + return account; + } + + public void updateBeneficiaries(Account account) { + String sql = "update T_ACCOUNT_BENEFICIARY SET SAVINGS = ? where ACCOUNT_ID = ? and NAME = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + for (Beneficiary beneficiary : account.getBeneficiaries()) { + ps.setBigDecimal(1, beneficiary.getSavings().asBigDecimal()); + ps.setLong(2, account.getEntityId()); + ps.setString(3, beneficiary.getName()); + ps.executeUpdate(); + } + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred updating beneficiary savings", e); + } + } + + /** + * Map the rows returned from the join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY to a fully-reconstituted Account + * aggregate. + * @param rs the set of rows returned from the query + * @return the mapped Account aggregate + * @throws SQLException an exception occurred extracting data from the result set + */ + private Account mapAccount(ResultSet rs) throws SQLException { + Account account = null; + while (rs.next()) { + if (account == null) { + String number = rs.getString("ACCOUNT_NUMBER"); + String name = rs.getString("ACCOUNT_NAME"); + account = new Account(number, name); + // set internal entity identifier (primary key) + account.setEntityId(rs.getLong("ID")); + } + account.restoreBeneficiary(mapBeneficiary(rs)); + } + if (account == null) { + // no rows returned - throw an empty result exception + throw new EmptyResultDataAccessException(1); + } + return account; + } + + /** + * Maps the beneficiary columns in a single row to an AllocatedBeneficiary object. + * @param rs the result set with its cursor positioned at the current row + * @return an allocated beneficiary + * @throws SQLException an exception occurred extracting data from the result set + */ + private Beneficiary mapBeneficiary(ResultSet rs) throws SQLException { + String name = rs.getString("BENEFICIARY_NAME"); + MonetaryAmount savings = MonetaryAmount.valueOf(rs.getString("BENEFICIARY_SAVINGS")); + Percentage allocationPercentage = Percentage.valueOf(rs.getString("BENEFICIARY_ALLOCATION_PERCENTAGE")); + return new Beneficiary(name, allocationPercentage, savings); + } +} \ No newline at end of file diff --git a/lab/24-test/src/main/java/rewards/internal/account/package.html b/lab/24-test/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/24-test/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/24-test/src/main/java/rewards/internal/package.html b/lab/24-test/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/24-test/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/24-test/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java b/lab/24-test/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java new file mode 100644 index 0000000..fab68fa --- /dev/null +++ b/lab/24-test/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java @@ -0,0 +1,120 @@ +package rewards.internal.restaurant; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.Map; + +import javax.sql.DataSource; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.stereotype.Repository; + +import common.money.Percentage; + +/** + * Loads restaurants from a data source using the JDBC API. + */ +@Repository +public class JdbcRestaurantRepository implements RestaurantRepository { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private DataSource dataSource; + + /** + * The Restaurant object cache. Cached restaurants are indexed by their merchant numbers. + */ + private Map restaurantCache; + + /** + * Constructor logs creation so we know which repository we are using. + */ + public JdbcRestaurantRepository() { + logger.info("Creating " + getClass().getSimpleName()); + } + + /** + * Sets the data source this repository will use to load restaurants. + * + * @param dataSource the data source + */ + @Autowired + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + return queryRestaurantCache(merchantNumber); + } + + /** + * Helper method that populates the {@link #restaurantCache restaurant object cache} from rows in the T_RESTAURANT + * table. Cached restaurants are indexed by their merchant numbers. This method is called on initialization. + */ + @PostConstruct + void populateRestaurantCache() { + logger.info("Loading restaurant cache"); + restaurantCache = new HashMap<>(); + String sql = "select MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE from T_RESTAURANT"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + Restaurant restaurant = mapRestaurant(rs); + // index the restaurant by its merchant number + restaurantCache.put(restaurant.getNumber(), restaurant); + } + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred finding by merchant number", e); + } + + logger.info("Finished loading restaurant cache"); + } + + /** + * Helper method that simply queries the cache of restaurants. + * + * @param merchantNumber the restaurant's merchant number + * @return the restaurant + * @throws EmptyResultDataAccessException if no restaurant was found with that merchant number + */ + private Restaurant queryRestaurantCache(String merchantNumber) { + Restaurant restaurant = restaurantCache.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } + + /** + * Helper method that clears the cache of restaurants. This method is called on destruction + */ + @PreDestroy + void clearRestaurantCache() { + logger.info("Clearing restaurant cache"); + restaurantCache.clear(); + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + * + * @param rs the result set with its cursor positioned at the current row + */ + private Restaurant mapRestaurant(ResultSet rs) throws SQLException { + // get the row column data + String name = rs.getString("NAME"); + String number = rs.getString("MERCHANT_NUMBER"); + Percentage benefitPercentage = Percentage.valueOf(rs.getString("BENEFIT_PERCENTAGE")); + // map to the object + Restaurant restaurant = new Restaurant(number, name); + restaurant.setBenefitPercentage(benefitPercentage); + return restaurant; + } +} \ No newline at end of file diff --git a/lab/24-test/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/24-test/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..230890c --- /dev/null +++ b/lab/24-test/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,80 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on an availability policy and a + * benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + return dining.getAmount().multiplyBy(benefitPercentage); + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage; + } +} \ No newline at end of file diff --git a/lab/24-test/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/24-test/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/24-test/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/24-test/src/main/java/rewards/internal/restaurant/package.html b/lab/24-test/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/24-test/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/24-test/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/24-test/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..b71a84d --- /dev/null +++ b/lab/24-test/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,72 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import javax.sql.DataSource; +import java.sql.*; + +/** + * JDBC implementation of a reward repository that records the result of a reward transaction by inserting a reward + * confirmation record. + */ +@Repository +public class JdbcRewardRepository implements RewardRepository { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private DataSource dataSource; + + /** + * Constructor logs creation so we know which repository we are using. + */ + public JdbcRewardRepository() { + logger.info("Creating " + getClass().getSimpleName()); + } + + /** + * Sets the data source this repository will use to insert rewards. + * @param dataSource the data source + */ + @Autowired + public void setDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + String confirmationNumber = nextConfirmationNumber(); + ps.setString(1, confirmationNumber); + ps.setBigDecimal(2, contribution.getAmount().asBigDecimal()); + ps.setDate(3, new Date(SimpleDate.today().inMilliseconds())); + ps.setString(4, contribution.getAccountNumber()); + ps.setString(5, dining.getMerchantNumber()); + ps.setDate(6, new Date(dining.getDate().inMilliseconds())); + ps.setBigDecimal(7, dining.getAmount().asBigDecimal()); + ps.execute(); + return new RewardConfirmation(confirmationNumber, contribution); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred inserting reward record", e); + } + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + rs.next(); + return rs.getString(1); + } catch (SQLException e) { + throw new RuntimeException("SQL exception getting next confirmation number", e); + } + } +} \ No newline at end of file diff --git a/lab/24-test/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/24-test/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/24-test/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/24-test/src/main/java/rewards/internal/reward/package.html b/lab/24-test/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/24-test/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/24-test/src/main/java/rewards/package.html b/lab/24-test/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/24-test/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/24-test/src/test/java/rewards/LoggingBeanPostProcessor.java b/lab/24-test/src/test/java/rewards/LoggingBeanPostProcessor.java new file mode 100644 index 0000000..d084def --- /dev/null +++ b/lab/24-test/src/test/java/rewards/LoggingBeanPostProcessor.java @@ -0,0 +1,35 @@ +package rewards; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; + +/** + * A simple bean post-processor that logs each newly created bean it sees. Inner + * beans are ignored. A very easy way to see what beans you have created and + * less verbose than turning on Spring's internal logging. + */ +public class LoggingBeanPostProcessor implements BeanPostProcessor { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) + throws BeansException { + return bean; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) + throws BeansException { + + // Log the names and types of all non inner-beans created + if (!beanName.contains("inner bean")) + logger.info("NEW " + bean.getClass().getSimpleName() + " -> " + + beanName); + + return bean; + } + +} diff --git a/lab/24-test/src/test/java/rewards/RewardNetworkTests.java b/lab/24-test/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..becedeb --- /dev/null +++ b/lab/24-test/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,153 @@ +package rewards; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ConfigurableApplicationContext; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * A system test that verifies the components of the RewardNetwork application + * work together to reward for dining successfully. Uses Spring to bootstrap the + * application for use in a test environment. + */ + +/* + * TODO-00: In this lab, you are going to exercise the following: + * - Using annotation(s) from Spring TestContext Framework for + * creating application context for the test + * - Using profiles in the test + * + * TODO-01: Use Spring TestContext Framework + * - Read through Spring document on Spring TestContext Framework + * (https://docs.spring.io/spring-framework/docs/current/spring-framework-reference/testing.html#testcontext-framework) + * - Add annotation(s) to this class so that it can + * use Spring TestContext Framework + * - Remove setUp() and tearDown() methods + * - Remove the attribute "context" which is not needed anymore. + * - Run the current test. Observe a test failure. + * - Use @Autowired to populate the rewardNetwork bean. + * - Re-run the current test, it should pass. + */ + +/* TODO-02: Annotate all 'Stub*Repository' classes with @Repository + * - In the package rewards/internal, annotate all 'Stub*Repository' classes + * with the @Repository annotation (WITHOUT specifying any profile yet). + * (Make sure you are changing code in the '24-test' project.) + * - Rerun the current test, it should fail. Why? + */ + +/* TODO-03: Assign the 'jdbc' profile to all Jdbc*Repository classes + * - Using the @Profile annotation, assign the 'jdbc' profile to all Jdbc*Repository classes + * (such as JdbcAccountRepository). (Be sure to annotate the actual repository classes in + * src/main/java, not the test classes in src/main/test!) + * - In the same way, assign the 'stub' profile to all Stub*Repository classes + * (such as StubAccountRepository) + * - Add @ActiveProfiles to this test class (below) and specify the "stub" profile. + * - Run the current test, it should pass. + * - Examine the logs, they should indicate "stub" repositories were used. + */ + +/* TODO-04: Change active-profile to "jdbc". + * - Rerun the test, it should pass. + * - Check which repository implementations are being used now. + */ + +/* TODO-05: Assign beans to the "local" profile + * - Go to corresponding step in TestInfrastructureLocalConfig class. + */ + +/* TODO-06: Use "jdbc" and "local" as active profiles + * - Now that the bean 'dataSource' is specific to the local profile, should we expect + * this test to be successful? + * - Make the appropriate changes so the current test uses 2 profiles ('jdbc' and 'local'). + * - Rerun the test, it should pass. + */ + +/* TODO-07: Use "jdbc" and "jndi" as active profiles + * - Open TestInfrastructureJndiConfig and note the different datasource that will be + * used if the profile = 'jndi'. + * - Now update the current test so it uses profiles 'jdbc' and 'jndi'. + * - Rerun the test, it should pass. + */ + +/* TODO-08 (Optional): Create an inner static class from TestInfrastructureConfig + * - Once inner static class is created, remove configuration + * class reference to TestInfrastructureConfig class from the annotation + * you added to this class in TO DO-01 above. (For more detailed on, refer tp + * lab document.) + * - Run the test again. + */ + +public class RewardNetworkTests { + + + /** + * The object being tested. + */ + private RewardNetwork rewardNetwork; + + /** + * Need this to enable clean shutdown at the end of the application + */ + private ConfigurableApplicationContext context; + + @BeforeEach + public void setUp() { + // Create the test configuration for the application from one file + context = SpringApplication.run(TestInfrastructureConfig.class); + // Get the bean to use to invoke the application + rewardNetwork = context.getBean(RewardNetwork.class); + } + + @AfterEach + public void tearDown() { + // simulate the Spring bean destruction lifecycle: + if (context != null) + context.close(); + } + + @Test + @DisplayName("Test if reward computation and distribution works") + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card + // '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", + "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork + .rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation + .getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 + // distributions + assertEquals(2, contribution.getDistributions().size()); + + // The total contribution amount should have been split into 2 distributions + // each distribution should be 4.00 (as both have a 50% allocation). + // The assertAll() is from JUnit 5 to group related checks together. + assertAll("distribution of reward", + () -> assertEquals(2, contribution.getDistributions().size()), + () -> assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()), + () -> assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount())); + } +} \ No newline at end of file diff --git a/lab/24-test/src/test/java/rewards/SimpleJndiHelper.java b/lab/24-test/src/test/java/rewards/SimpleJndiHelper.java new file mode 100644 index 0000000..384da61 --- /dev/null +++ b/lab/24-test/src/test/java/rewards/SimpleJndiHelper.java @@ -0,0 +1,68 @@ +package rewards; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.naming.Context; +import javax.naming.InitialContext; +import javax.naming.NamingException; +import javax.sql.DataSource; + +/** + * This class sets up Simple JNDI, creates an embedded dataSource and registers + * it with JNDI. Normally JNDI is provided by a container such as Tomcat. This + * allows JNDI to be used standalone in a test. + *

+ * We need this to be registered and available before any Spring beans are + * created, or our JNDI lookup will fail. We have therefore made this bean into + * a BeanFactoryPostProcessor - it will be invoked just before the + * first bean is created. + */ +public class SimpleJndiHelper implements BeanFactoryPostProcessor { + + public static final String REWARDS_DB_JNDI_PATH = "java:/comp/env/jdbc/rewards"; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + public void doJndiSetup() { + System.setProperty(Context.INITIAL_CONTEXT_FACTORY, "org.osjava.sj.SimpleContextFactory"); + System.setProperty("org.osjava.sj.root", "jndi"); + System.setProperty("org.osjava.jndi.delimiter", "/"); + System.setProperty("org.osjava.sj.jndi.shared", "true"); + + logger.info("Running JDNI setup"); + + try { + InitialContext ic = new InitialContext(); + + // Construct DataSource + DataSource ds = new EmbeddedDatabaseBuilder().addScript("classpath:rewards/testdb/schema.sql") + .addScript("classpath:rewards/testdb/data.sql").build(); + + // Bind as a JNDI resource + ic.rebind(REWARDS_DB_JNDI_PATH, ds); + logger.info("JNDI Resource '" + REWARDS_DB_JNDI_PATH + "' instanceof " + ds.getClass().getSimpleName()); + } catch (NamingException ex) { + logger.error("JNDI setup failed", ex); + ex.printStackTrace(); + System.exit(0); + } + + logger.info("JNDI Registrations completed."); + } + + /** + * Using the BeanFactoryPostProcessor as a convenient entry-point to do setup + * before Spring creates any beans. + */ + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + doJndiSetup(); + return; + } + +} diff --git a/lab/24-test/src/test/java/rewards/TestInfrastructureConfig.java b/lab/24-test/src/test/java/rewards/TestInfrastructureConfig.java new file mode 100644 index 0000000..371cfde --- /dev/null +++ b/lab/24-test/src/test/java/rewards/TestInfrastructureConfig.java @@ -0,0 +1,23 @@ +package rewards; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import config.RewardsConfig; + +@Configuration +@Import({ + TestInfrastructureLocalConfig.class, + TestInfrastructureJndiConfig.class, + RewardsConfig.class }) +public class TestInfrastructureConfig { + + /** + * The bean logging post-processor from the bean lifecycle slides. + */ + @Bean + public static LoggingBeanPostProcessor loggingBean(){ + return new LoggingBeanPostProcessor(); + } +} diff --git a/lab/24-test/src/test/java/rewards/TestInfrastructureJndiConfig.java b/lab/24-test/src/test/java/rewards/TestInfrastructureJndiConfig.java new file mode 100644 index 0000000..136f6a6 --- /dev/null +++ b/lab/24-test/src/test/java/rewards/TestInfrastructureJndiConfig.java @@ -0,0 +1,38 @@ +package rewards; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +import javax.naming.InitialContext; +import javax.sql.DataSource; + +/** + * Sets up a JNDI service for our test. + * + * See SimpleJndiHelper class to see how this works. + */ +@Configuration +@Profile("jndi") +public class TestInfrastructureJndiConfig { + + /** + * Static method because we are defining a Bean post-processor. + */ + @Bean + public static SimpleJndiHelper jndiHelper() { + return new SimpleJndiHelper(); + } + + /** + * Create the data-source by doing a JNDI lookup. + * + * @return The data-source if found + * @throws Exception + * Any lookup error. + */ + @Bean + public DataSource dataSource() throws Exception { + return (DataSource) (new InitialContext()).lookup("java:/comp/env/jdbc/rewards"); + } +} diff --git a/lab/24-test/src/test/java/rewards/TestInfrastructureLocalConfig.java b/lab/24-test/src/test/java/rewards/TestInfrastructureLocalConfig.java new file mode 100644 index 0000000..329b0c9 --- /dev/null +++ b/lab/24-test/src/test/java/rewards/TestInfrastructureLocalConfig.java @@ -0,0 +1,27 @@ +package rewards; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +/* TODO-05: Update this configuration class so that its + * beans are members of the "local" profile. + */ +@Configuration +public class TestInfrastructureLocalConfig { + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ + @Bean + public DataSource dataSource(){ + return + (new EmbeddedDatabaseBuilder()) + .addScript("classpath:rewards/testdb/schema.sql") + .addScript("classpath:rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/24-test/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/24-test/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..758750c --- /dev/null +++ b/lab/24-test/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,75 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertAll; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import common.money.MonetaryAmount; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + @DisplayName("test if reward computation and distribution works") + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + // each distribution should be 4.00 (as both have a 50% allocation) + assertAll("distribution of reward", + () -> assertEquals(2, contribution.getDistributions().size()), + () -> assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()), + () -> assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount())); + + } +} \ No newline at end of file diff --git a/lab/24-test/src/test/java/rewards/internal/StubAccountRepository.java b/lab/24-test/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..376289f --- /dev/null +++ b/lab/24-test/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,54 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account + * "Keith and Keri Donald" with two beneficiaries "Annabelle" (50% allocation) + * and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can + * work with this stub and not have to bring in expensive and/or complex + * dependencies such as a Database. Simple unit tests can then verify object + * behavior by considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final Map accountsByCreditCard = new HashMap<>(); + + /** + * Creates a single test account with two beneficiaries. Also logs creation + * so we know which repository we are using. + */ + public StubAccountRepository() { + logger.info("Creating " + getClass().getSimpleName()); + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new EmptyResultDataAccessException(1); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/24-test/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/24-test/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..be3df7a --- /dev/null +++ b/lab/24-test/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,50 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant + * "Apple Bees" with a 8% benefit availability percentage that's always + * available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can + * work with this stub and not have to bring in expensive and/or complex + * dependencies such as a Database. Simple unit tests can then verify object + * behavior by considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + /** + * Creates a single test restaurant with an 8% benefit policy. Also logs + * creation so we know which repository we are using. + */ + public StubRestaurantRepository() { + logger.info("Creating " + getClass().getSimpleName()); + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber + .get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } +} \ No newline at end of file diff --git a/lab/24-test/src/test/java/rewards/internal/StubRewardRepository.java b/lab/24-test/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..2546788 --- /dev/null +++ b/lab/24-test/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,34 @@ +package rewards.internal; + +import java.util.Random; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + /** + * Constructor logs creation so we know which repository we are using. + */ + public StubRewardRepository() { + logger.info("Creating " + getClass().getSimpleName()); + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/24-test/src/test/java/rewards/internal/account/AccountTests.java b/lab/24-test/src/test/java/rewards/internal/account/AccountTests.java new file mode 100644 index 0000000..c18c1c9 --- /dev/null +++ b/lab/24-test/src/test/java/rewards/internal/account/AccountTests.java @@ -0,0 +1,68 @@ +package rewards.internal.account; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import rewards.AccountContribution; + +/** + * Unit tests for the Account class that verify Account behavior works in isolation. + */ +public class AccountTests { + + private final Account account = new Account("1", "Keith and Keri Donald"); + + @Test + public void accountIsValid() { + // setup account with a valid set of beneficiaries to prepare for testing + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + assertTrue(account.isValid()); + } + + @Test + public void accountIsInvalidWithNoBeneficiaries() { + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreOver100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreUnder100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("25%")); + assertFalse(account.isValid()); + } + + @Test + public void makeContribution() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("100.00")); + assertEquals(contribution.getAmount(), MonetaryAmount.valueOf("100.00")); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Corgan").getAmount()); + } + + @Test + public void throwIllegalStateExceptionWhenContributionIsInvalid() { + Throwable exception = assertThrows(IllegalStateException.class, + () -> { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + account.makeContribution(MonetaryAmount.valueOf("100.00")); + }); + assertEquals("Cannot make contributions to this account: it has invalid beneficiary allocations", exception.getMessage()); + } +} \ No newline at end of file diff --git a/lab/24-test/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java b/lab/24-test/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java new file mode 100644 index 0000000..4326571 --- /dev/null +++ b/lab/24-test/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java @@ -0,0 +1,96 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC account repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcAccountRepositoryTests { + + private JdbcAccountRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + repository = new JdbcAccountRepository(); + repository.setDataSource(dataSource); + } + + @Test + public void testFindAccountByCreditCard() { + Account account = repository.findByCreditCard("1234123412341234"); + // assert the returned account contains what you expect given the state of the database + assertNotNull(account, "account should never be null"); + assertEquals(Long.valueOf(0), account.getEntityId(), "wrong entity id"); + assertEquals("123456789", account.getNumber(), "wrong account number"); + assertEquals("Keith and Keri Donald", account.getName(), "wrong name"); + assertEquals(2, account.getBeneficiaries().size(), "wrong beneficiary collection size"); + + Beneficiary b1 = account.getBeneficiary("Annabelle"); + assertNotNull(b1, "Annabelle should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b1.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b1.getAllocationPercentage(), "wrong allocation percentage"); + + Beneficiary b2 = account.getBeneficiary("Corgan"); + assertNotNull(b2, "Corgan should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b2.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b2.getAllocationPercentage(), "wrong allocation percentage"); + } + + @Test + public void testFindAccountByCreditCardNoAccount() { + assertThrows(EmptyResultDataAccessException.class, () -> { + repository.findByCreditCard("bogus"); + }); + } + + @Test + public void testUpdateBeneficiaries() throws SQLException { + Account account = repository.findByCreditCard("1234123412341234"); + account.makeContribution(MonetaryAmount.valueOf("8.00")); + repository.updateBeneficiaries(account); + verifyBeneficiaryTableUpdated(); + } + + private void verifyBeneficiaryTableUpdated() throws SQLException { + String sql = "select SAVINGS from T_ACCOUNT_BENEFICIARY where NAME = ? and ACCOUNT_ID = ?"; + PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql); + + // assert Annabelle has $4.00 savings now + stmt.setString(1, "Annabelle"); + stmt.setLong(2, 0L); + ResultSet rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + + // assert Corgan has $4.00 savings now + stmt.setString(1, "Corgan"); + stmt.setLong(2, 0L); + rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/24-test/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java b/lab/24-test/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java new file mode 100644 index 0000000..3f16cd3 --- /dev/null +++ b/lab/24-test/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java @@ -0,0 +1,77 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC restaurant repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRestaurantRepositoryTests { + + private JdbcRestaurantRepository repository; + + @BeforeEach + public void setUp() { + // simulate the Spring bean initialization lifecycle: + + // first, construct the bean + repository = new JdbcRestaurantRepository(); + + // then, inject its dependencies + repository.setDataSource(createTestDataSource()); + + // lastly, initialize the bean + repository.populateRestaurantCache(); + } + + @AfterEach + public void tearDown() { + // simulate the Spring bean destruction lifecycle: + + // destroy the bean + repository.clearRestaurantCache(); + } + + @Test + public void findRestaurantByMerchantNumber() { + Restaurant restaurant = repository.findByMerchantNumber("1234567890"); + assertNotNull(restaurant, "the restaurant should never be null"); + assertEquals("1234567890", restaurant.getNumber(), "the merchant number is wrong"); + assertEquals("AppleBees", restaurant.getName(), "the name is wrong"); + assertEquals(Percentage.valueOf("8%"), restaurant.getBenefitPercentage(), "the benefitPercentage is wrong"); + } + + @Test + public void testFindRestaurantByBogusMerchantNumber() { + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("bogus"); + }); + } + + @Test + public void restaurantCacheClearedAfterDestroy() { + // force early tear down + tearDown(); + + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("1234567890"); + }); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/24-test/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java b/lab/24-test/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java new file mode 100644 index 0000000..b366df4 --- /dev/null +++ b/lab/24-test/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java @@ -0,0 +1,81 @@ +package rewards.internal.reward; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.Account; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Tests the JDBC reward repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRewardRepositoryTests { + + private JdbcRewardRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + repository = new JdbcRewardRepository(); + dataSource = createTestDataSource(); + repository.setDataSource(dataSource); + } + + @Test + public void testCreateReward() throws SQLException { + Dining dining = Dining.createDining("100.00", "1234123412341234", "0123456789"); + + Account account = new Account("1", "Keith and Keri Donald"); + account.setEntityId(0L); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("8.00")); + RewardConfirmation confirmation = repository.confirmReward(contribution, dining); + assertNotNull(confirmation, "confirmation should not be null"); + assertNotNull(confirmation.getConfirmationNumber(), "confirmation number should not be null"); + assertEquals(contribution, confirmation.getAccountContribution(), "wrong contribution object"); + verifyRewardInserted(confirmation, dining); + } + + private void verifyRewardInserted(RewardConfirmation confirmation, Dining dining) throws SQLException { + assertEquals(1, getRewardCount()); + Statement stmt = dataSource.getConnection().createStatement(); + ResultSet rs = stmt.executeQuery("select REWARD_AMOUNT from T_REWARD where CONFIRMATION_NUMBER = '" + + confirmation.getConfirmationNumber() + "'"); + rs.next(); + assertEquals(confirmation.getAccountContribution().getAmount(), MonetaryAmount.valueOf(rs.getString(1))); + } + + private int getRewardCount() throws SQLException { + Statement stmt = dataSource.getConnection().createStatement(); + ResultSet rs = stmt.executeQuery("select count(*) from T_REWARD"); + rs.next(); + return rs.getInt(1); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/24-test/src/test/resources/jndi/jndi.properties b/lab/24-test/src/test/resources/jndi/jndi.properties new file mode 100644 index 0000000..3bfd943 --- /dev/null +++ b/lab/24-test/src/test/resources/jndi/jndi.properties @@ -0,0 +1,4 @@ +java.naming.factory.initial=org.osjava.sj.SimpleContextFactory +org.osjava.sj.root=target/test-classes/config +org.osjava.jndi.delimiter=/ +org.osjava.sj.jndi.shared=true \ No newline at end of file diff --git a/lab/26-jdbc-solution/build.gradle b/lab/26-jdbc-solution/build.gradle new file mode 100644 index 0000000..af9f0d4 --- /dev/null +++ b/lab/26-jdbc-solution/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation project(':00-rewards-common') +} diff --git a/lab/26-jdbc-solution/pom.xml b/lab/26-jdbc-solution/pom.xml new file mode 100644 index 0000000..d5f53a9 --- /dev/null +++ b/lab/26-jdbc-solution/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + 26-jdbc-solution + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + diff --git a/lab/26-jdbc-solution/src/main/java/config/RewardsConfig.java b/lab/26-jdbc-solution/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..ec20e11 --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/config/RewardsConfig.java @@ -0,0 +1,52 @@ +package config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import rewards.RewardNetwork; +import rewards.internal.RewardNetworkImpl; +import rewards.internal.account.AccountRepository; +import rewards.internal.account.JdbcAccountRepository; +import rewards.internal.restaurant.JdbcRestaurantRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.JdbcRewardRepository; +import rewards.internal.reward.RewardRepository; + +import javax.sql.DataSource; + +@Configuration +public class RewardsConfig { + + final JdbcTemplate jdbcTemplate; + + public RewardsConfig(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + @Bean + public RewardNetwork rewardNetwork(){ + return new RewardNetworkImpl( + accountRepository(), + restaurantRepository(), + rewardRepository()); + } + + @Bean + public AccountRepository accountRepository(){ + JdbcAccountRepository repository = new JdbcAccountRepository(jdbcTemplate); + return repository; + } + + @Bean + public RestaurantRepository restaurantRepository(){ + JdbcRestaurantRepository repository = new JdbcRestaurantRepository(jdbcTemplate); + return repository; + } + + @Bean + public RewardRepository rewardRepository(){ + JdbcRewardRepository repository = new JdbcRewardRepository(jdbcTemplate); + return repository; + } + +} diff --git a/lab/26-jdbc-solution/src/main/java/rewards/AccountContribution.java b/lab/26-jdbc-solution/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/main/java/rewards/Dining.java b/lab/26-jdbc-solution/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/main/java/rewards/RewardConfirmation.java b/lab/26-jdbc-solution/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/main/java/rewards/RewardNetwork.java b/lab/26-jdbc-solution/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/26-jdbc-solution/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..cb3191e --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,52 @@ +package rewards.internal; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/account/Account.java b/lab/26-jdbc-solution/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..84463f0 --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,159 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + try { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } catch (IllegalArgumentException e) { + // total would have been over 100% - return invalid + return false; + } + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. Callers should not attempt to hold on or modify the returned set. + * This method should only be used transitively; for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Returns a single account beneficiary. Callers should not attempt to hold on or modify the returned object. This + * method should only be used transitively; for example, called to facilitate reporting or testing. + * @param name the name of the beneficiary e.g "Annabelle" + * @return the beneficiary object + */ + public Beneficiary getBeneficiary(String name) { + for (Beneficiary b : beneficiaries) { + if (b.getName().equals(name)) { + return b; + } + } + throw new IllegalArgumentException("No such beneficiary with name '" + name + "'"); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/account/AccountRepository.java b/lab/26-jdbc-solution/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..16c6079 --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/account/Beneficiary.java b/lab/26-jdbc-solution/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java b/lab/26-jdbc-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java new file mode 100644 index 0000000..06e2a4e --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java @@ -0,0 +1,112 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.ResultSetExtractor; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Loads accounts from a data source using the JDBC API. + */ +public class JdbcAccountRepository implements AccountRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcAccountRepository(JdbcTemplate jdbcTemplate) { + + this.jdbcTemplate = jdbcTemplate; + } + + /** + * Extracts an Account object from rows returned from a join of T_ACCOUNT and + * T_ACCOUNT_BENEFICIARY. + */ + private final ResultSetExtractor accountExtractor = new AccountExtractor(); + + public Account findByCreditCard(String creditCardNumber) { + String sql = """ + select a.ID as ID, a.NUMBER as ACCOUNT_NUMBER, a.NAME as ACCOUNT_NAME,\ + c.NUMBER as CREDIT_CARD_NUMBER,\ + b.NAME as BENEFICIARY_NAME, b.ALLOCATION_PERCENTAGE as BENEFICIARY_ALLOCATION_PERCENTAGE,\ + b.SAVINGS as BENEFICIARY_SAVINGS\ + from T_ACCOUNT a, T_ACCOUNT_BENEFICIARY b, T_ACCOUNT_CREDIT_CARD c\ + where ID = b.ACCOUNT_ID and ID = c.ACCOUNT_ID and c.NUMBER = ?\ + """; + return jdbcTemplate.query(sql, accountExtractor, creditCardNumber); + } + + public void updateBeneficiaries(Account account) { + String sql = "update T_ACCOUNT_BENEFICIARY SET SAVINGS = ? where ACCOUNT_ID = ? and NAME = ?"; + for (Beneficiary b : account.getBeneficiaries()) { + jdbcTemplate.update(sql, b.getSavings().asBigDecimal(), account.getEntityId(), b.getName()); + } + } + + /** + * Map the rows returned from the join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY to + * a fully-reconstituted Account aggregate. + * + * @param rs + * the set of rows returned from the query + * @return the mapped Account aggregate + * @throws SQLException + * an exception occurred extracting data from the result set + */ + private Account mapAccount(ResultSet rs) throws SQLException { + Account account = null; + + while (rs.next()) { + if (account == null) { + String number = rs.getString("ACCOUNT_NUMBER"); + String name = rs.getString("ACCOUNT_NAME"); + account = new Account(number, name); + // set internal entity identifier (primary key) + account.setEntityId(rs.getLong("ID")); + } + + Beneficiary beneficiary = mapBeneficiary(rs); + if (beneficiary != null) + account.restoreBeneficiary(mapBeneficiary(rs)); + } + + if (account == null) { + // no rows returned - throw an empty result exception + throw new EmptyResultDataAccessException(1); + } + + return account; + } + + /** + * Maps the beneficiary columns in a single row to an AllocatedBeneficiary + * object. + * + * @param rs + * the result set with its cursor positioned at the current row + * @return an allocated beneficiary + * @throws SQLException + * an exception occurred extracting data from the result set + */ + private Beneficiary mapBeneficiary(ResultSet rs) throws SQLException { + String name = rs.getString("BENEFICIARY_NAME"); + + if (name == null) + return null; // No beneficiary + + MonetaryAmount savings = MonetaryAmount.valueOf(rs.getString("BENEFICIARY_SAVINGS")); + Percentage allocationPercentage = Percentage.valueOf(rs.getString("BENEFICIARY_ALLOCATION_PERCENTAGE")); + return new Beneficiary(name, allocationPercentage, savings); + } + + private class AccountExtractor implements ResultSetExtractor { + + public Account extractData(ResultSet rs) throws SQLException, DataAccessException { + return mapAccount(rs); + } + } +} diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/account/package.html b/lab/26-jdbc-solution/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/package.html b/lab/26-jdbc-solution/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java b/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java new file mode 100644 index 0000000..b7d6d74 --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java @@ -0,0 +1,20 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * Determines if benefit is available for an account for dining. + * + * A value object. A strategy. Scoped by the Resturant aggregate. + */ +public interface BenefitAvailabilityPolicy { + + /** + * Calculates if an account is eligible to receive benefits for a dining. + * @param account the account of the member who dined + * @param dining the dining event + * @return benefit availability status + */ + boolean isBenefitAvailableFor(Account account, Dining dining); +} diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java b/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java new file mode 100644 index 0000000..aa4a789 --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java @@ -0,0 +1,114 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import rewards.Dining; +import rewards.internal.account.Account; + +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Loads restaurants from a data source using the JDBC API. + */ +public class JdbcRestaurantRepository implements RestaurantRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcRestaurantRepository(JdbcTemplate jdbcTemplate) { + + this.jdbcTemplate = jdbcTemplate; + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + */ + private final RowMapper rowMapper = new RestaurantRowMapper(); + + public Restaurant findByMerchantNumber(String merchantNumber) { + String sql = "select MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY from T_RESTAURANT where MERCHANT_NUMBER = ?"; + return jdbcTemplate.queryForObject(sql, rowMapper, merchantNumber); + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + * + * @param rs the result set with its cursor positioned at the current row + */ + private Restaurant mapRestaurant(ResultSet rs) throws SQLException { + // get the row column data + String name = rs.getString("NAME"); + String number = rs.getString("MERCHANT_NUMBER"); + Percentage benefitPercentage = Percentage.valueOf(rs.getString("BENEFIT_PERCENTAGE")); + // map to the object + Restaurant restaurant = new Restaurant(number, name); + restaurant.setBenefitPercentage(benefitPercentage); + restaurant.setBenefitAvailabilityPolicy(mapBenefitAvailabilityPolicy(rs)); + return restaurant; + } + + /** + * Helper method that maps benefit availability policy data in the ResultSet to a fully-configured + * {@link BenefitAvailabilityPolicy} object. The key column is 'BENEFIT_AVAILABILITY_POLICY', which is a + * discriminator column containing a string code that identifies the type of policy. Currently supported types are: + * 'A' for 'always available' and 'N' for 'never available'. + * + *

+ * More types could be added easily by enhancing this method. For example, 'W' for 'Weekdays only' or 'M' for 'Max + * Rewards per Month'. Some of these types might require additional database column values to be configured, for + * example a 'MAX_REWARDS_PER_MONTH' data column. + * + * @param rs the result set used to map the policy object from database column values + * @return the matching benefit availability policy + * @throws IllegalArgumentException if the mapping could not be performed + */ + private BenefitAvailabilityPolicy mapBenefitAvailabilityPolicy(ResultSet rs) throws SQLException { + String policyCode = rs.getString("BENEFIT_AVAILABILITY_POLICY"); + if ("A".equals(policyCode)) { + return AlwaysAvailable.INSTANCE; + } else if ("N".equals(policyCode)) { + return NeverAvailable.INSTANCE; + } else { + throw new IllegalArgumentException("Not a supported policy code " + policyCode); + } + } + + /** + * Returns true indicating benefit is always available. + */ + static class AlwaysAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new AlwaysAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + + public String toString() { + return "alwaysAvailable"; + } + } + + /** + * Returns false indicating benefit is never available. + */ + static class NeverAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new NeverAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return false; + } + + public String toString() { + return "neverAvailable"; + } + } + + private class RestaurantRowMapper implements RowMapper { + + public Restaurant mapRow(ResultSet rs, int rowNum) throws SQLException { + return mapRestaurant(rs); + } + + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..2e73e57 --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,102 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on an availability policy and a + * benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + private BenefitAvailabilityPolicy benefitAvailabilityPolicy; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Sets the policy that determines if a dining by an account at this restaurant is eligible for benefit. + * @param benefitAvailabilityPolicy the benefit availability policy + */ + public void setBenefitAvailabilityPolicy(BenefitAvailabilityPolicy benefitAvailabilityPolicy) { + this.benefitAvailabilityPolicy = benefitAvailabilityPolicy; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Returns this restaurant's benefit availability policy. + */ + public BenefitAvailabilityPolicy getBenefitAvailabilityPolicy() { + return benefitAvailabilityPolicy; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + if (benefitAvailabilityPolicy.isBenefitAvailableFor(account, dining)) { + return dining.getAmount().multiplyBy(benefitPercentage); + } else { + return MonetaryAmount.zero(); + } + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage + + ", benefitAvailabilityPolicy = " + benefitAvailabilityPolicy; + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/package.html b/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/26-jdbc-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..01d4fd3 --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,35 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import org.springframework.jdbc.core.JdbcTemplate; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * JDBC implementation of a reward repository that records the result of a reward transaction by inserting a reward + * confirmation record. + */ +public class JdbcRewardRepository implements RewardRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcRewardRepository(JdbcTemplate jdbcTemplate) { + + this.jdbcTemplate = jdbcTemplate; + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + String confirmationNumber = nextConfirmationNumber(); + jdbcTemplate.update(sql, confirmationNumber, contribution.getAmount().asBigDecimal(), + SimpleDate.today().asDate(), contribution.getAccountNumber(), dining.getMerchantNumber(), + dining.getDate().asDate(), dining.getAmount().asBigDecimal()); + return new RewardConfirmation(confirmationNumber, contribution); + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + return jdbcTemplate.queryForObject(sql, String.class); + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/26-jdbc-solution/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/main/java/rewards/internal/reward/package.html b/lab/26-jdbc-solution/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/26-jdbc-solution/src/main/java/rewards/package.html b/lab/26-jdbc-solution/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/26-jdbc-solution/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/26-jdbc-solution/src/test/java/rewards/RewardNetworkTests.java b/lab/26-jdbc-solution/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..8597f35 --- /dev/null +++ b/lab/26-jdbc-solution/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,53 @@ +package rewards; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A system test that verifies the components of the RewardNetwork application work together to reward for dining + * successfully. Uses Spring to bootstrap the application for use in a test environment. + */ +@SpringJUnitConfig(classes = {SystemTestConfig.class}) +public class RewardNetworkTests { + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/test/java/rewards/SystemTestConfig.java b/lab/26-jdbc-solution/src/test/java/rewards/SystemTestConfig.java new file mode 100644 index 0000000..fbc4dec --- /dev/null +++ b/lab/26-jdbc-solution/src/test/java/rewards/SystemTestConfig.java @@ -0,0 +1,31 @@ +package rewards; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import config.RewardsConfig; + + +@Configuration +@Import(RewardsConfig.class) +public class SystemTestConfig { + + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ + @Bean + public DataSource dataSource(){ + return + (new EmbeddedDatabaseBuilder()) + .addScript("classpath:rewards/testdb/schema.sql") + .addScript("classpath:rewards/testdb/data.sql") + .build(); + } + +} diff --git a/lab/26-jdbc-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/26-jdbc-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..98b7353 --- /dev/null +++ b/lab/26-jdbc-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,72 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/test/java/rewards/internal/StubAccountRepository.java b/lab/26-jdbc-solution/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..b926be2 --- /dev/null +++ b/lab/26-jdbc-solution/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,43 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new EmptyResultDataAccessException(1); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/26-jdbc-solution/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..418516d --- /dev/null +++ b/lab/26-jdbc-solution/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,53 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.Dining; +import rewards.internal.account.Account; +import rewards.internal.restaurant.BenefitAvailabilityPolicy; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new AlwaysReturnsTrue()); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } + + /** + * A simple "dummy" benefit availability policy that always returns true. Only useful for testing--a real + * availability policy might consider many factors such as the day of week of the dining, or the account's reward + * history for the current month. + */ + private static class AlwaysReturnsTrue implements BenefitAvailabilityPolicy { + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/test/java/rewards/internal/StubRewardRepository.java b/lab/26-jdbc-solution/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..2487aca --- /dev/null +++ b/lab/26-jdbc-solution/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import java.util.Random; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/test/java/rewards/internal/account/AccountTests.java b/lab/26-jdbc-solution/src/test/java/rewards/internal/account/AccountTests.java new file mode 100644 index 0000000..4075654 --- /dev/null +++ b/lab/26-jdbc-solution/src/test/java/rewards/internal/account/AccountTests.java @@ -0,0 +1,57 @@ +package rewards.internal.account; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for the Account class that verify Account behavior works in isolation. + */ +public class AccountTests { + + private final Account account = new Account("1", "Keith and Keri Donald"); + + @Test + public void accountIsValid() { + // setup account with a valid set of beneficiaries to prepare for testing + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + assertTrue(account.isValid()); + } + + @Test + public void accountIsInvalidWithNoBeneficiaries() { + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreOver100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreUnder100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("25%")); + assertFalse(account.isValid()); + } + + @Test + public void makeContribution() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("100.00")); + assertEquals(contribution.getAmount(), MonetaryAmount.valueOf("100.00")); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java b/lab/26-jdbc-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java new file mode 100644 index 0000000..15e0fa1 --- /dev/null +++ b/lab/26-jdbc-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java @@ -0,0 +1,102 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC account repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcAccountRepositoryTests { + + private JdbcAccountRepository repository; + + private DataSource dataSource; + + private JdbcTemplate jdbcTemplate; + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + repository = new JdbcAccountRepository(createTestJdbcTemplate()); + } + + @Test + public void testFindAccountByCreditCard() { + Account account = repository.findByCreditCard("1234123412341234"); + // assert the returned account contains what you expect given the state of the database + assertNotNull(account, "account should never be null"); + assertEquals(Long.valueOf(0), account.getEntityId(), "wrong entity id"); + assertEquals("123456789", account.getNumber(), "wrong account number"); + assertEquals("Keith and Keri Donald", account.getName(), "wrong name"); + assertEquals(2, account.getBeneficiaries().size(), "wrong beneficiary collection size"); + + Beneficiary b1 = account.getBeneficiary("Annabelle"); + assertNotNull(b1, "Annabelle should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b1.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b1.getAllocationPercentage(), "wrong allocation percentage"); + + Beneficiary b2 = account.getBeneficiary("Corgan"); + assertNotNull(b2, "Corgan should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b2.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b2.getAllocationPercentage(), "wrong allocation percentage"); + } + + @Test + public void testFindAccountByCreditCardNoAccount() { + assertThrows(EmptyResultDataAccessException.class, () -> { + repository.findByCreditCard("bogus"); + }); + } + + @Test + public void testUpdateBeneficiaries() throws SQLException { + Account account = repository.findByCreditCard("1234123412341234"); + account.makeContribution(MonetaryAmount.valueOf("8.00")); + repository.updateBeneficiaries(account); + verifyBeneficiaryTableUpdated(); + } + + private void verifyBeneficiaryTableUpdated() throws SQLException { + String sql = "select SAVINGS from T_ACCOUNT_BENEFICIARY where NAME = ? and ACCOUNT_ID = ?"; + PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql); + + // assert Annabelle has $4.00 savings now + stmt.setString(1, "Annabelle"); + stmt.setLong(2, 0L); + ResultSet rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + + // assert Corgan has $4.00 savings now + stmt.setString(1, "Corgan"); + stmt.setLong(2, 0L); + rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } + + private JdbcTemplate createTestJdbcTemplate() { + return new JdbcTemplate(createTestDataSource()); + } +} diff --git a/lab/26-jdbc-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java b/lab/26-jdbc-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java new file mode 100644 index 0000000..29c0996 --- /dev/null +++ b/lab/26-jdbc-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java @@ -0,0 +1,56 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC restaurant repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRestaurantRepositoryTests { + + private JdbcRestaurantRepository repository; + + @BeforeEach + public void setUp() { + repository = new JdbcRestaurantRepository(createTestJdbcTemplate()); + } + + @Test + public void testFindRestaurantByMerchantNumber() { + Restaurant restaurant = repository.findByMerchantNumber("1234567890"); + assertNotNull(restaurant, "the restaurant should never be null"); + assertEquals("1234567890", restaurant.getNumber(), "the merchant number is wrong"); + assertEquals("AppleBees", restaurant.getName(), "the name is wrong"); + assertEquals(Percentage.valueOf("8%"), restaurant.getBenefitPercentage(), "the benefitPercentage is wrong"); + assertEquals(JdbcRestaurantRepository.AlwaysAvailable.INSTANCE, + restaurant.getBenefitAvailabilityPolicy(), "the benefit availability policy is wrong"); + } + + @Test + public void testFindRestaurantByBogusMerchantNumber() { + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("bogus"); + }); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } + + private JdbcTemplate createTestJdbcTemplate() { + return new JdbcTemplate(createTestDataSource()); + } +} diff --git a/lab/26-jdbc-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java b/lab/26-jdbc-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java new file mode 100644 index 0000000..93c2496 --- /dev/null +++ b/lab/26-jdbc-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java @@ -0,0 +1,71 @@ +package rewards.internal.restaurant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for exercising the behavior of the Restaurant aggregate entity. A restaurant calculates a benefit to award + * to an account for dining based on an availability policy and benefit percentage. + */ +public class RestaurantTests { + + private Restaurant restaurant; + + private Account account; + + private Dining dining; + + @BeforeEach + public void setUp() { + // configure the restaurant, the object being tested + restaurant = new Restaurant("1234567890", "AppleBee's"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(true)); + // configure supporting objects needed by the restaurant + account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle"); + dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + } + + @Test + public void testCalcuateBenefitFor() { + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert 8.00 eligible for reward + assertEquals(MonetaryAmount.valueOf("8.00"), benefit); + } + + @Test + public void testNoBenefitAvailable() { + // configure stub that always returns false + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(false)); + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert zero eligible for reward + assertEquals(MonetaryAmount.valueOf("0.00"), benefit); + } + + /** + * A simple "dummy" benefit availability policy containing a single flag used to determine if benefit is available. + * Only useful for testing--a real availability policy might consider many factors such as the day of week of the + * dining, or the account's reward history for the current month. + */ + private static class StubBenefitAvailibilityPolicy implements BenefitAvailabilityPolicy { + + private final boolean isBenefitAvailable; + + public StubBenefitAvailibilityPolicy(boolean isBenefitAvailable) { + this.isBenefitAvailable = isBenefitAvailable; + } + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return isBenefitAvailable; + } + } +} \ No newline at end of file diff --git a/lab/26-jdbc-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java b/lab/26-jdbc-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java new file mode 100644 index 0000000..dcd58f7 --- /dev/null +++ b/lab/26-jdbc-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java @@ -0,0 +1,93 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.Account; + +import javax.sql.DataSource; +import java.math.BigDecimal; +import java.sql.SQLException; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests the JDBC reward repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRewardRepositoryTests { + + private JdbcRewardRepository repository; + + private DataSource dataSource; + + private JdbcTemplate jdbcTemplate; + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + jdbcTemplate = createTestJdbcTemplate(); + repository = new JdbcRewardRepository(jdbcTemplate); + + } + + @Test + public void testCreateReward() throws SQLException { + Dining dining = Dining.createDining("100.00", "1234123412341234", "0123456789"); + + Account account = new Account("1", "Keith and Keri Donald"); + account.setEntityId(0L); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("8.00")); + RewardConfirmation confirmation = repository.confirmReward(contribution, dining); + assertNotNull(confirmation, "confirmation should not be null"); + assertNotNull(confirmation.getConfirmationNumber(), "confirmation number should not be null"); + assertEquals(contribution, confirmation.getAccountContribution(), "wrong contribution object"); + verifyRewardInserted(confirmation, dining); + } + + private void verifyRewardInserted(RewardConfirmation confirmation, Dining dining) { + assertEquals(1, getRewardCount()); + String sql = "select * from T_REWARD where CONFIRMATION_NUMBER = ?"; + Map values = jdbcTemplate.queryForMap(sql, confirmation.getConfirmationNumber()); + verifyInsertedValues(confirmation, dining, values); + } + + private void verifyInsertedValues(RewardConfirmation confirmation, Dining dining, Map values) { + assertEquals(confirmation.getAccountContribution().getAmount(), new MonetaryAmount((BigDecimal) values + .get("REWARD_AMOUNT"))); + assertEquals(SimpleDate.today().asDate(), values.get("REWARD_DATE")); + assertEquals(confirmation.getAccountContribution().getAccountNumber(), values.get("ACCOUNT_NUMBER")); + assertEquals(dining.getAmount(), new MonetaryAmount((BigDecimal) values.get("DINING_AMOUNT"))); + assertEquals(dining.getMerchantNumber(), values.get("DINING_MERCHANT_NUMBER")); + assertEquals(SimpleDate.today().asDate(), values.get("DINING_DATE")); + } + + private int getRewardCount() { + String sql = "select count(*) from T_REWARD"; + return jdbcTemplate.queryForObject(sql, Integer.class); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } + + private JdbcTemplate createTestJdbcTemplate() { + return new JdbcTemplate(createTestDataSource()); + } +} diff --git a/lab/26-jdbc-solution/src/test/resources/rewards/testdb/data.sql b/lab/26-jdbc-solution/src/test/resources/rewards/testdb/data.sql new file mode 100644 index 0000000..28a87cc --- /dev/null +++ b/lab/26-jdbc-solution/src/test/resources/rewards/testdb/data.sql @@ -0,0 +1,78 @@ + +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (17, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) + values ('1234567890', 'AppleBees', .08, 'A'); diff --git a/lab/26-jdbc-solution/src/test/resources/rewards/testdb/schema.sql b/lab/26-jdbc-solution/src/test/resources/rewards/testdb/schema.sql new file mode 100644 index 0000000..b0324fa --- /dev/null +++ b/lab/26-jdbc-solution/src/test/resources/rewards/testdb/schema.sql @@ -0,0 +1,20 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, unique(NUMBER)); +create table T_ACCOUNT_CREDIT_CARD (ID integer identity primary key, ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(3,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_CREDIT_CARD add constraint FK_ACCOUNT_CREDIT_CARD foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; \ No newline at end of file diff --git a/lab/26-jdbc/build.gradle b/lab/26-jdbc/build.gradle new file mode 100644 index 0000000..76eed19 --- /dev/null +++ b/lab/26-jdbc/build.gradle @@ -0,0 +1,7 @@ +dependencies { + implementation project(':00-rewards-common') +} + +test { + exclude '**/JdbcRewardRepositoryTests.class' +} diff --git a/lab/26-jdbc/pom.xml b/lab/26-jdbc/pom.xml new file mode 100644 index 0000000..afab982 --- /dev/null +++ b/lab/26-jdbc/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + 26-jdbc + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/JdbcRewardRepositoryTests.java + + + + + + diff --git a/lab/26-jdbc/src/main/java/config/RewardsConfig.java b/lab/26-jdbc/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..56431c5 --- /dev/null +++ b/lab/26-jdbc/src/main/java/config/RewardsConfig.java @@ -0,0 +1,50 @@ +package config; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import rewards.RewardNetwork; +import rewards.internal.RewardNetworkImpl; +import rewards.internal.account.AccountRepository; +import rewards.internal.account.JdbcAccountRepository; +import rewards.internal.restaurant.JdbcRestaurantRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.JdbcRewardRepository; +import rewards.internal.reward.RewardRepository; + +@Configuration +public class RewardsConfig { + + @Autowired + DataSource dataSource; + + @Bean + public RewardNetwork rewardNetwork(){ + return new RewardNetworkImpl( + accountRepository(), + restaurantRepository(), + rewardRepository()); + } + + @Bean + public AccountRepository accountRepository(){ + JdbcAccountRepository repository = new JdbcAccountRepository(dataSource); + return repository; + } + + @Bean + public RestaurantRepository restaurantRepository(){ + JdbcRestaurantRepository repository = new JdbcRestaurantRepository(dataSource); + return repository; + } + + @Bean + public RewardRepository rewardRepository(){ + JdbcRewardRepository repository = new JdbcRewardRepository(dataSource); + return repository; + } + +} diff --git a/lab/26-jdbc/src/main/java/rewards/AccountContribution.java b/lab/26-jdbc/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/main/java/rewards/Dining.java b/lab/26-jdbc/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/main/java/rewards/RewardConfirmation.java b/lab/26-jdbc/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/main/java/rewards/RewardNetwork.java b/lab/26-jdbc/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/26-jdbc/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/26-jdbc/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..cb3191e --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,52 @@ +package rewards.internal; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/main/java/rewards/internal/account/Account.java b/lab/26-jdbc/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..84463f0 --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,159 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + try { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } catch (IllegalArgumentException e) { + // total would have been over 100% - return invalid + return false; + } + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. Callers should not attempt to hold on or modify the returned set. + * This method should only be used transitively; for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Returns a single account beneficiary. Callers should not attempt to hold on or modify the returned object. This + * method should only be used transitively; for example, called to facilitate reporting or testing. + * @param name the name of the beneficiary e.g "Annabelle" + * @return the beneficiary object + */ + public Beneficiary getBeneficiary(String name) { + for (Beneficiary b : beneficiaries) { + if (b.getName().equals(name)) { + return b; + } + } + throw new IllegalArgumentException("No such beneficiary with name '" + name + "'"); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/main/java/rewards/internal/account/AccountRepository.java b/lab/26-jdbc/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..16c6079 --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/26-jdbc/src/main/java/rewards/internal/account/Beneficiary.java b/lab/26-jdbc/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/main/java/rewards/internal/account/JdbcAccountRepository.java b/lab/26-jdbc/src/main/java/rewards/internal/account/JdbcAccountRepository.java new file mode 100644 index 0000000..ca3a566 --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/account/JdbcAccountRepository.java @@ -0,0 +1,150 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.springframework.dao.EmptyResultDataAccessException; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Loads accounts from a data source using the JDBC API. + */ + +// TODO-10 (Optional) : Inject JdbcTemplate directly to this repository class +// - Refactor the constructor to get the JdbcTemplate injected directly +// (instead of DataSource getting injected) +// - Refactor RewardsConfig accordingly +// - Refactor JdbcAccountRepositoryTests accordingly +// - Run JdbcAccountRepositoryTests and verity it passes + +// TODO-05: Refactor this repository to use JdbcTemplate. +// - Add a field of type JdbcTemplate. +// - Refactor the code in the constructor to instantiate the JdbcTemplate +// object using the given DataSource object. +public class JdbcAccountRepository implements AccountRepository { + + private final DataSource dataSource; + + public JdbcAccountRepository(DataSource dataSource) { + this.dataSource = dataSource; + } + + // TODO-07 (Optional): Refactor this method using JdbcTemplate and ResultSetExtractor + // - Create a ResultSetExtractor object and pass it as an argument + // to jdbcTemplate.query(..) method + // - Let the extractData() method of the ResultSetExtractor to call + // mapAccount() method, which is provided in this class, to do all the work. + // - Run the JdbcAccountRepositoryTests class. It should pass. + public Account findByCreditCard(String creditCardNumber) { + String sql = """ + select a.ID as ID, a.NUMBER as ACCOUNT_NUMBER, a.NAME as ACCOUNT_NAME, c.NUMBER as CREDIT_CARD_NUMBER, \ + b.NAME as BENEFICIARY_NAME, b.ALLOCATION_PERCENTAGE as BENEFICIARY_ALLOCATION_PERCENTAGE, b.SAVINGS as BENEFICIARY_SAVINGS \ + from T_ACCOUNT a, T_ACCOUNT_CREDIT_CARD c \ + left outer join T_ACCOUNT_BENEFICIARY b \ + on a.ID = b.ACCOUNT_ID \ + where c.ACCOUNT_ID = a.ID and c.NUMBER = ?\ + """; + + Account account = null; + Connection conn = null; + PreparedStatement ps = null; + ResultSet rs = null; + try { + conn = dataSource.getConnection(); + ps = conn.prepareStatement(sql); + ps.setString(1, creditCardNumber); + rs = ps.executeQuery(); + account = mapAccount(rs); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred finding by credit card number", e); + } finally { + if (rs != null) { + try { + // Close to prevent database cursor exhaustion + rs.close(); + } catch (SQLException ex) { + } + } + if (ps != null) { + try { + // Close to prevent database cursor exhaustion + ps.close(); + } catch (SQLException ex) { + } + } + if (conn != null) { + try { + // Close to prevent database connection exhaustion + conn.close(); + } catch (SQLException ex) { + } + } + } + return account; + } + + // TODO-06: Refactor this method to use JdbcTemplate. + // - Note that an account has multiple beneficiaries + // and you are going to perform UPDATE operation using + // JdbcTemplate for each of those beneficiaries + // - Rerun the JdbcAccountRepositoryTests and verify it passes + public void updateBeneficiaries(Account account) { + String sql = "update T_ACCOUNT_BENEFICIARY SET SAVINGS = ? where ACCOUNT_ID = ? and NAME = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + for (Beneficiary beneficiary : account.getBeneficiaries()) { + ps.setBigDecimal(1, beneficiary.getSavings().asBigDecimal()); + ps.setLong(2, account.getEntityId()); + ps.setString(3, beneficiary.getName()); + ps.executeUpdate(); + } + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred updating beneficiary savings", e); + } + } + + /** + * Map the rows returned from the join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY to a fully-reconstituted Account + * aggregate. + * + * @param rs the set of rows returned from the query + * @return the mapped Account aggregate + * @throws SQLException an exception occurred extracting data from the result set + */ + private Account mapAccount(ResultSet rs) throws SQLException { + Account account = null; + while (rs.next()) { + if (account == null) { + String number = rs.getString("ACCOUNT_NUMBER"); + String name = rs.getString("ACCOUNT_NAME"); + account = new Account(number, name); + // set internal entity identifier (primary key) + account.setEntityId(rs.getLong("ID")); + } + account.restoreBeneficiary(mapBeneficiary(rs)); + } + if (account == null) { + // no rows returned - throw an empty result exception + throw new EmptyResultDataAccessException(1); + } + return account; + } + + /** + * Maps the beneficiary columns in a single row to an AllocatedBeneficiary object. + * + * @param rs the result set with its cursor positioned at the current row + * @return an allocated beneficiary + * @throws SQLException an exception occurred extracting data from the result set + */ + private Beneficiary mapBeneficiary(ResultSet rs) throws SQLException { + String name = rs.getString("BENEFICIARY_NAME"); + MonetaryAmount savings = MonetaryAmount.valueOf(rs.getString("BENEFICIARY_SAVINGS")); + Percentage allocationPercentage = Percentage.valueOf(rs.getString("BENEFICIARY_ALLOCATION_PERCENTAGE")); + return new Beneficiary(name, allocationPercentage, savings); + } +} diff --git a/lab/26-jdbc/src/main/java/rewards/internal/account/package.html b/lab/26-jdbc/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/26-jdbc/src/main/java/rewards/internal/package.html b/lab/26-jdbc/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/26-jdbc/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java b/lab/26-jdbc/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java new file mode 100644 index 0000000..b7d6d74 --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java @@ -0,0 +1,20 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * Determines if benefit is available for an account for dining. + * + * A value object. A strategy. Scoped by the Resturant aggregate. + */ +public interface BenefitAvailabilityPolicy { + + /** + * Calculates if an account is eligible to receive benefits for a dining. + * @param account the account of the member who dined + * @param dining the dining event + * @return benefit availability status + */ + boolean isBenefitAvailableFor(Account account, Dining dining); +} diff --git a/lab/26-jdbc/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java b/lab/26-jdbc/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java new file mode 100644 index 0000000..c8b155e --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java @@ -0,0 +1,150 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.springframework.dao.EmptyResultDataAccessException; +import rewards.Dining; +import rewards.internal.account.Account; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Loads restaurants from a data source using the JDBC API. + */ + +// TODO-09 (Optional) : Inject JdbcTemplate directly to this repository class +// - Refactor the constructor to get the JdbcTemplate injected directly +// (instead of DataSource getting injected) +// - Refactor RewardsConfig accordingly +// - Refactor JdbcRestaurantRepositoryTests accordingly +// - Run JdbcRestaurantRepositoryTests and verity it passes + +// TODO-04: Refactor the cumbersome low-level JDBC code to use JdbcTemplate. +// - Run JdbcRestaurantRepositoryTests and verity it passes +// - Add a field of type JdbcTemplate +// - Refactor the code in the constructor to instantiate JdbcTemplate object +// from the given DataSource object +// - Refactor findByMerchantNumber(..) to use the JdbcTemplate and a RowMapper +// +// #1: Create a RowMapper object and pass it to the +// jdbcTemplate.queryForObject(..) method as an argument +// #2: The mapRestaurant(..) method provided in this class contains +// logic, which the RowMapper may wish to use +// +// - Run JdbcRestaurantRepositoryTests again and verity it passes + +public class JdbcRestaurantRepository implements RestaurantRepository { + + private final DataSource dataSource; + + public JdbcRestaurantRepository(DataSource dataSource) { + this.dataSource = dataSource; + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + String sql = """ + select MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY\ + from T_RESTAURANT where MERCHANT_NUMBER = ?\ + """; + Restaurant restaurant = null; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql) ){ + ps.setString(1, merchantNumber); + ResultSet rs = ps.executeQuery(); + advanceToNextRow(rs); + restaurant = mapRestaurant(rs); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred finding by merchant number", e); + } + + return restaurant; + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + * @param rs the result set with its cursor positioned at the current row + */ + private Restaurant mapRestaurant(ResultSet rs) throws SQLException { + // Get the row column data + String name = rs.getString("NAME"); + String number = rs.getString("MERCHANT_NUMBER"); + Percentage benefitPercentage = Percentage.valueOf(rs.getString("BENEFIT_PERCENTAGE")); + + // Map to the object + Restaurant restaurant = new Restaurant(number, name); + restaurant.setBenefitPercentage(benefitPercentage); + restaurant.setBenefitAvailabilityPolicy(mapBenefitAvailabilityPolicy(rs)); + return restaurant; + } + + /** + * Advances a ResultSet to the next row and throws an exception if there are no rows. + * @param rs the ResultSet to advance + * @throws EmptyResultDataAccessException if there is no next row + * @throws SQLException + */ + private void advanceToNextRow(ResultSet rs) throws EmptyResultDataAccessException, SQLException { + if (!rs.next()) { + throw new EmptyResultDataAccessException(1); + } + } + + /** + * Helper method that maps benefit availability policy data in the ResultSet to a fully-configured + * {@link BenefitAvailabilityPolicy} object. The key column is 'BENEFIT_AVAILABILITY_POLICY', which is a + * discriminator column containing a string code that identifies the type of policy. Currently supported types are: + * 'A' for 'always available' and 'N' for 'never available'. + * + * More types could be added easily by enhancing this method. For example, 'W' for 'Weekdays only' or 'M' for 'Max + * Rewards per Month'. Some of these types might require additional database column values to be configured, for + * example a 'MAX_REWARDS_PER_MONTH' data column. + * + * @param rs the result set used to map the policy object from database column values + * @return the matching benefit availability policy + * @throws IllegalArgumentException if the mapping could not be performed + */ + private BenefitAvailabilityPolicy mapBenefitAvailabilityPolicy(ResultSet rs) throws SQLException { + String policyCode = rs.getString("BENEFIT_AVAILABILITY_POLICY"); + if ("A".equals(policyCode)) { + return AlwaysAvailable.INSTANCE; + } else if ("N".equals(policyCode)) { + return NeverAvailable.INSTANCE; + } else { + throw new IllegalArgumentException("Not a supported policy code " + policyCode); + } + } + + /** + * Returns true indicating benefit is always available. + */ + static class AlwaysAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new AlwaysAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + + public String toString() { + return "alwaysAvailable"; + } + } + + /** + * Returns false indicating benefit is never available. + */ + static class NeverAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new NeverAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return false; + } + + public String toString() { + return "neverAvailable"; + } + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/26-jdbc/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..2e73e57 --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,102 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on an availability policy and a + * benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + private BenefitAvailabilityPolicy benefitAvailabilityPolicy; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Sets the policy that determines if a dining by an account at this restaurant is eligible for benefit. + * @param benefitAvailabilityPolicy the benefit availability policy + */ + public void setBenefitAvailabilityPolicy(BenefitAvailabilityPolicy benefitAvailabilityPolicy) { + this.benefitAvailabilityPolicy = benefitAvailabilityPolicy; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Returns this restaurant's benefit availability policy. + */ + public BenefitAvailabilityPolicy getBenefitAvailabilityPolicy() { + return benefitAvailabilityPolicy; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + if (benefitAvailabilityPolicy.isBenefitAvailableFor(account, dining)) { + return dining.getAmount().multiplyBy(benefitPercentage); + } else { + return MonetaryAmount.zero(); + } + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage + + ", benefitAvailabilityPolicy = " + benefitAvailabilityPolicy; + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/26-jdbc/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/26-jdbc/src/main/java/rewards/internal/restaurant/package.html b/lab/26-jdbc/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/26-jdbc/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/26-jdbc/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..6b8d7e7 --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,83 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import javax.sql.DataSource; +import java.sql.*; + +/** + * JDBC implementation of a reward repository that records the result + * of a reward transaction by inserting a reward confirmation record. + */ + +// TODO-08 (Optional) : Inject JdbcTemplate directly to this repository class +// - Refactor the constructor to get the JdbcTemplate injected directly +// (instead of DataSource getting injected) +// - Refactor RewardsConfig accordingly +// - Refactor JdbcRewardRepositoryTests accordingly +// - Run JdbcRewardRepositoryTests and verity it passes + +// TODO-03: Refactor the cumbersome low-level JDBC code in JdbcRewardRepository with JdbcTemplate. +// - Add a field of type JdbcTemplate. +// - Refactor the code in the constructor to instantiate JdbcTemplate +// object from the given DataSource object. +// - Refactor the confirmReward(...) and nextConfirmationNumber() methods to use +// the JdbcTemplate object. +// +// DO NOT delete the old JDBC code, just comment out the try/catch block. +// You will need to refer to the old JDBC code to write the new code. +// +// - Run JdbcRewardRepositoryTests and verity it passes +// (If you are using Gradle, make sure to comment out the exclude statement +// in the test task in the build.gradle.) + +public class JdbcRewardRepository implements RewardRepository { + + private final DataSource dataSource; + + public JdbcRewardRepository(DataSource dataSource) { + this.dataSource = dataSource; + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + String confirmationNumber = nextConfirmationNumber(); + + // Update the T_REWARD table with the new Reward + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + + ps.setString(1, confirmationNumber); + ps.setBigDecimal(2, contribution.getAmount().asBigDecimal()); + ps.setDate(3, new Date(SimpleDate.today().inMilliseconds())); + ps.setString(4, contribution.getAccountNumber()); + ps.setString(5, dining.getMerchantNumber()); + ps.setDate(6, new Date(dining.getDate().inMilliseconds())); + ps.setBigDecimal(7, dining.getAmount().asBigDecimal()); + ps.execute(); + } catch (SQLException e) { + throw new RuntimeException("SQL exception occurred inserting reward record", e); + } + + return new RewardConfirmation(confirmationNumber, contribution); + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + String nextValue; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + rs.next(); + nextValue = rs.getString(1); + } catch (SQLException e) { + throw new RuntimeException("SQL exception getting next confirmation number", e); + } + + return nextValue; + } +} diff --git a/lab/26-jdbc/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/26-jdbc/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/26-jdbc/src/main/java/rewards/internal/reward/package.html b/lab/26-jdbc/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/26-jdbc/src/main/java/rewards/package.html b/lab/26-jdbc/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/26-jdbc/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/26-jdbc/src/test/java/rewards/RewardNetworkTests.java b/lab/26-jdbc/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..8597f35 --- /dev/null +++ b/lab/26-jdbc/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,53 @@ +package rewards; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A system test that verifies the components of the RewardNetwork application work together to reward for dining + * successfully. Uses Spring to bootstrap the application for use in a test environment. + */ +@SpringJUnitConfig(classes = {SystemTestConfig.class}) +public class RewardNetworkTests { + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/test/java/rewards/SystemTestConfig.java b/lab/26-jdbc/src/test/java/rewards/SystemTestConfig.java new file mode 100644 index 0000000..fbc4dec --- /dev/null +++ b/lab/26-jdbc/src/test/java/rewards/SystemTestConfig.java @@ -0,0 +1,31 @@ +package rewards; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import config.RewardsConfig; + + +@Configuration +@Import(RewardsConfig.class) +public class SystemTestConfig { + + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ + @Bean + public DataSource dataSource(){ + return + (new EmbeddedDatabaseBuilder()) + .addScript("classpath:rewards/testdb/schema.sql") + .addScript("classpath:rewards/testdb/data.sql") + .build(); + } + +} diff --git a/lab/26-jdbc/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/26-jdbc/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..98b7353 --- /dev/null +++ b/lab/26-jdbc/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,72 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/test/java/rewards/internal/StubAccountRepository.java b/lab/26-jdbc/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..b926be2 --- /dev/null +++ b/lab/26-jdbc/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,43 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new EmptyResultDataAccessException(1); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/26-jdbc/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..418516d --- /dev/null +++ b/lab/26-jdbc/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,53 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.Dining; +import rewards.internal.account.Account; +import rewards.internal.restaurant.BenefitAvailabilityPolicy; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new AlwaysReturnsTrue()); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } + + /** + * A simple "dummy" benefit availability policy that always returns true. Only useful for testing--a real + * availability policy might consider many factors such as the day of week of the dining, or the account's reward + * history for the current month. + */ + private static class AlwaysReturnsTrue implements BenefitAvailabilityPolicy { + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/test/java/rewards/internal/StubRewardRepository.java b/lab/26-jdbc/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..2487aca --- /dev/null +++ b/lab/26-jdbc/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import java.util.Random; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/test/java/rewards/internal/account/AccountTests.java b/lab/26-jdbc/src/test/java/rewards/internal/account/AccountTests.java new file mode 100644 index 0000000..4075654 --- /dev/null +++ b/lab/26-jdbc/src/test/java/rewards/internal/account/AccountTests.java @@ -0,0 +1,57 @@ +package rewards.internal.account; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for the Account class that verify Account behavior works in isolation. + */ +public class AccountTests { + + private final Account account = new Account("1", "Keith and Keri Donald"); + + @Test + public void accountIsValid() { + // setup account with a valid set of beneficiaries to prepare for testing + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + assertTrue(account.isValid()); + } + + @Test + public void accountIsInvalidWithNoBeneficiaries() { + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreOver100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreUnder100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("25%")); + assertFalse(account.isValid()); + } + + @Test + public void makeContribution() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("100.00")); + assertEquals(contribution.getAmount(), MonetaryAmount.valueOf("100.00")); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java b/lab/26-jdbc/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java new file mode 100644 index 0000000..b1615ad --- /dev/null +++ b/lab/26-jdbc/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java @@ -0,0 +1,95 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC account repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcAccountRepositoryTests { + + private JdbcAccountRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + repository = new JdbcAccountRepository(dataSource); + } + + @Test + public void testFindAccountByCreditCard() { + Account account = repository.findByCreditCard("1234123412341234"); + // assert the returned account contains what you expect given the state of the database + assertNotNull(account, "account should never be null"); + assertEquals(Long.valueOf(0), account.getEntityId(), "wrong entity id"); + assertEquals("123456789", account.getNumber(), "wrong account number"); + assertEquals("Keith and Keri Donald", account.getName(), "wrong name"); + assertEquals(2, account.getBeneficiaries().size(), "wrong beneficiary collection size"); + + Beneficiary b1 = account.getBeneficiary("Annabelle"); + assertNotNull(b1, "Annabelle should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b1.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b1.getAllocationPercentage(), "wrong allocation percentage"); + + Beneficiary b2 = account.getBeneficiary("Corgan"); + assertNotNull(b2, "Corgan should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b2.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b2.getAllocationPercentage(), "wrong allocation percentage"); + } + + @Test + public void testFindAccountByCreditCardNoAccount() { + assertThrows(EmptyResultDataAccessException.class, () -> { + repository.findByCreditCard("bogus"); + }); + } + + @Test + public void testUpdateBeneficiaries() throws SQLException { + Account account = repository.findByCreditCard("1234123412341234"); + account.makeContribution(MonetaryAmount.valueOf("8.00")); + repository.updateBeneficiaries(account); + verifyBeneficiaryTableUpdated(); + } + + private void verifyBeneficiaryTableUpdated() throws SQLException { + String sql = "select SAVINGS from T_ACCOUNT_BENEFICIARY where NAME = ? and ACCOUNT_ID = ?"; + PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql); + + // assert Annabelle has $4.00 savings now + stmt.setString(1, "Annabelle"); + stmt.setLong(2, 0L); + ResultSet rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + + // assert Corgan has $4.00 savings now + stmt.setString(1, "Corgan"); + stmt.setLong(2, 0L); + rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/26-jdbc/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java b/lab/26-jdbc/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java new file mode 100644 index 0000000..06ca758 --- /dev/null +++ b/lab/26-jdbc/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java @@ -0,0 +1,51 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC restaurant repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRestaurantRepositoryTests { + + private JdbcRestaurantRepository repository; + + @BeforeEach + public void setUp() { + repository = new JdbcRestaurantRepository(createTestDataSource()); + } + + @Test + public void testFindRestaurantByMerchantNumber() { + Restaurant restaurant = repository.findByMerchantNumber("1234567890"); + assertNotNull(restaurant, "the restaurant should never be null"); + assertEquals("1234567890", restaurant.getNumber(), "the merchant number is wrong"); + assertEquals("AppleBees", restaurant.getName(), "the name is wrong"); + assertEquals(Percentage.valueOf("8%"), restaurant.getBenefitPercentage(), "the benefitPercentage is wrong"); + assertEquals(JdbcRestaurantRepository.AlwaysAvailable.INSTANCE, + restaurant.getBenefitAvailabilityPolicy(), "the benefit availability policy is wrong"); + } + + @Test + public void testFindRestaurantByBogusMerchantNumber() { + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("bogus"); + }); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/26-jdbc/src/test/java/rewards/internal/restaurant/RestaurantTests.java b/lab/26-jdbc/src/test/java/rewards/internal/restaurant/RestaurantTests.java new file mode 100644 index 0000000..93c2496 --- /dev/null +++ b/lab/26-jdbc/src/test/java/rewards/internal/restaurant/RestaurantTests.java @@ -0,0 +1,71 @@ +package rewards.internal.restaurant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for exercising the behavior of the Restaurant aggregate entity. A restaurant calculates a benefit to award + * to an account for dining based on an availability policy and benefit percentage. + */ +public class RestaurantTests { + + private Restaurant restaurant; + + private Account account; + + private Dining dining; + + @BeforeEach + public void setUp() { + // configure the restaurant, the object being tested + restaurant = new Restaurant("1234567890", "AppleBee's"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(true)); + // configure supporting objects needed by the restaurant + account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle"); + dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + } + + @Test + public void testCalcuateBenefitFor() { + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert 8.00 eligible for reward + assertEquals(MonetaryAmount.valueOf("8.00"), benefit); + } + + @Test + public void testNoBenefitAvailable() { + // configure stub that always returns false + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(false)); + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert zero eligible for reward + assertEquals(MonetaryAmount.valueOf("0.00"), benefit); + } + + /** + * A simple "dummy" benefit availability policy containing a single flag used to determine if benefit is available. + * Only useful for testing--a real availability policy might consider many factors such as the day of week of the + * dining, or the account's reward history for the current month. + */ + private static class StubBenefitAvailibilityPolicy implements BenefitAvailabilityPolicy { + + private final boolean isBenefitAvailable; + + public StubBenefitAvailibilityPolicy(boolean isBenefitAvailable) { + this.isBenefitAvailable = isBenefitAvailable; + } + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return isBenefitAvailable; + } + } +} \ No newline at end of file diff --git a/lab/26-jdbc/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java b/lab/26-jdbc/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java new file mode 100644 index 0000000..46f8be8 --- /dev/null +++ b/lab/26-jdbc/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java @@ -0,0 +1,105 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.Account; + +import javax.sql.DataSource; +import java.math.BigDecimal; +import java.sql.SQLException; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests the JDBC reward repository with a test data source to verify + * data access and relational-to-object mapping behavior works as expected. + * + * TODO-00: In this lab, you are going to exercise the following: + * - Refactoring cumbersome low-level JDBC code to leverage Spring's JdbcTemplate + * - Using various query methods of JdbcTemplate for retrieving data + * - Implementing callbacks for converting retrieved data into domain object + * - RowMapper + * - ResultSetExtractor (optional) + */ +public class JdbcRewardRepositoryTests { + + private JdbcRewardRepository repository; + + private DataSource dataSource; + + private JdbcTemplate jdbcTemplate; + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + repository = new JdbcRewardRepository(dataSource); + jdbcTemplate = new JdbcTemplate(dataSource); + } + + @Test + public void testCreateReward() throws SQLException { + Dining dining = Dining.createDining("100.00", "1234123412341234", "0123456789"); + + Account account = new Account("1", "Keith and Keri Donald"); + account.setEntityId(0L); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("8.00")); + RewardConfirmation confirmation = repository.confirmReward(contribution, dining); + assertNotNull(confirmation, "confirmation should not be null"); + assertNotNull(confirmation.getConfirmationNumber(), "confirmation number should not be null"); + assertEquals(contribution, confirmation.getAccountContribution(), "wrong contribution object"); + verifyRewardInserted(confirmation, dining); + } + + private void verifyRewardInserted(RewardConfirmation confirmation, Dining dining) { + assertEquals(1, getRewardCount()); + + // TODO-02: Use JdbcTemplate to query for a map of all column values + // of a row in the T_REWARD table based on the confirmationNumber. + // - Use "SELECT * FROM T_REWARD WHERE CONFIRMATION_NUMBER = ?" as SQL statement + // - After making the changes, execute this test class to verify + // its successful execution. + // (If you are using Gradle, comment out the test exclude in + // the build.gradle file.) + // + + Map values = null; + verifyInsertedValues(confirmation, dining, values); + } + + private void verifyInsertedValues(RewardConfirmation confirmation, Dining dining, Map values) { + assertEquals(confirmation.getAccountContribution().getAmount(), new MonetaryAmount((BigDecimal) values + .get("REWARD_AMOUNT"))); + assertEquals(SimpleDate.today().asDate(), values.get("REWARD_DATE")); + assertEquals(confirmation.getAccountContribution().getAccountNumber(), values.get("ACCOUNT_NUMBER")); + assertEquals(dining.getAmount(), new MonetaryAmount((BigDecimal) values.get("DINING_AMOUNT"))); + assertEquals(dining.getMerchantNumber(), values.get("DINING_MERCHANT_NUMBER")); + assertEquals(SimpleDate.today().asDate(), values.get("DINING_DATE")); + } + + private int getRewardCount() { + // TODO-01: Use JdbcTemplate to query for the number of rows in the T_REWARD table + // - Use "SELECT count(*) FROM T_REWARD" as SQL statement + return -1; + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/26-jdbc/src/test/resources/rewards/testdb/data.sql b/lab/26-jdbc/src/test/resources/rewards/testdb/data.sql new file mode 100644 index 0000000..28a87cc --- /dev/null +++ b/lab/26-jdbc/src/test/resources/rewards/testdb/data.sql @@ -0,0 +1,78 @@ + +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (17, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) + values ('1234567890', 'AppleBees', .08, 'A'); diff --git a/lab/26-jdbc/src/test/resources/rewards/testdb/schema.sql b/lab/26-jdbc/src/test/resources/rewards/testdb/schema.sql new file mode 100644 index 0000000..b0324fa --- /dev/null +++ b/lab/26-jdbc/src/test/resources/rewards/testdb/schema.sql @@ -0,0 +1,20 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, unique(NUMBER)); +create table T_ACCOUNT_CREDIT_CARD (ID integer identity primary key, ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(3,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_CREDIT_CARD add constraint FK_ACCOUNT_CREDIT_CARD foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; \ No newline at end of file diff --git a/lab/28-transactions-solution/build.gradle b/lab/28-transactions-solution/build.gradle new file mode 100644 index 0000000..af9f0d4 --- /dev/null +++ b/lab/28-transactions-solution/build.gradle @@ -0,0 +1,3 @@ +dependencies { + implementation project(':00-rewards-common') +} diff --git a/lab/28-transactions-solution/pom.xml b/lab/28-transactions-solution/pom.xml new file mode 100644 index 0000000..95fdf88 --- /dev/null +++ b/lab/28-transactions-solution/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + 28-transactions-solution + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + diff --git a/lab/28-transactions-solution/src/main/java/config/RewardsConfig.java b/lab/28-transactions-solution/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..d311007 --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/config/RewardsConfig.java @@ -0,0 +1,56 @@ +package config; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import rewards.RewardNetwork; +import rewards.internal.RewardNetworkImpl; +import rewards.internal.account.AccountRepository; +import rewards.internal.account.JdbcAccountRepository; +import rewards.internal.restaurant.JdbcRestaurantRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.JdbcRewardRepository; +import rewards.internal.reward.RewardRepository; + + +@Configuration +@EnableTransactionManagement +public class RewardsConfig { + + @Autowired + DataSource dataSource; + + @Bean + public RewardNetwork rewardNetwork(){ + return new RewardNetworkImpl( + accountRepository(), + restaurantRepository(), + rewardRepository()); + } + + @Bean + public AccountRepository accountRepository(){ + JdbcAccountRepository repository = new JdbcAccountRepository(); + repository.setDataSource(dataSource); + return repository; + } + + @Bean + public RestaurantRepository restaurantRepository(){ + JdbcRestaurantRepository repository = new JdbcRestaurantRepository(); + repository.setDataSource(dataSource); + return repository; + } + + @Bean + public RewardRepository rewardRepository(){ + JdbcRewardRepository repository = new JdbcRewardRepository(); + repository.setDataSource(dataSource); + return repository; + } + +} diff --git a/lab/28-transactions-solution/src/main/java/rewards/AccountContribution.java b/lab/28-transactions-solution/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/main/java/rewards/Dining.java b/lab/28-transactions-solution/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/main/java/rewards/RewardConfirmation.java b/lab/28-transactions-solution/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/main/java/rewards/RewardNetwork.java b/lab/28-transactions-solution/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/28-transactions-solution/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..8de8f12 --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,55 @@ +package rewards.internal; + +import org.springframework.transaction.annotation.Transactional; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + @Transactional + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/RewardNetworkImplRequiresNew.java b/lab/28-transactions-solution/src/main/java/rewards/internal/RewardNetworkImplRequiresNew.java new file mode 100644 index 0000000..3433b81 --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/RewardNetworkImplRequiresNew.java @@ -0,0 +1,56 @@ +package rewards.internal; + +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +public class RewardNetworkImplRequiresNew implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImplRequiresNew(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + @Transactional(propagation=Propagation.REQUIRES_NEW) + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/account/Account.java b/lab/28-transactions-solution/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..84463f0 --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,159 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + try { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } catch (IllegalArgumentException e) { + // total would have been over 100% - return invalid + return false; + } + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. Callers should not attempt to hold on or modify the returned set. + * This method should only be used transitively; for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Returns a single account beneficiary. Callers should not attempt to hold on or modify the returned object. This + * method should only be used transitively; for example, called to facilitate reporting or testing. + * @param name the name of the beneficiary e.g "Annabelle" + * @return the beneficiary object + */ + public Beneficiary getBeneficiary(String name) { + for (Beneficiary b : beneficiaries) { + if (b.getName().equals(name)) { + return b; + } + } + throw new IllegalArgumentException("No such beneficiary with name '" + name + "'"); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/account/AccountRepository.java b/lab/28-transactions-solution/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..16c6079 --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/account/Beneficiary.java b/lab/28-transactions-solution/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java b/lab/28-transactions-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java new file mode 100644 index 0000000..e1ddf17 --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java @@ -0,0 +1,92 @@ +package rewards.internal.account; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.springframework.dao.DataAccessException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.ResultSetExtractor; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Loads accounts from a data source using the JDBC API. + */ +public class JdbcAccountRepository implements AccountRepository { + + private JdbcTemplate jdbcTemplate; + + /** + * Extracts an Account object from rows returned from a join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY. + */ + private final ResultSetExtractor accountExtractor = new AccountExtractor(); + + public void setDataSource(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + public Account findByCreditCard(String creditCardNumber) { + String sql = "select a.ID as ID, a.NUMBER as ACCOUNT_NUMBER, a.NAME as ACCOUNT_NAME, c.NUMBER as CREDIT_CARD_NUMBER, b.NAME as BENEFICIARY_NAME, b.ALLOCATION_PERCENTAGE as BENEFICIARY_ALLOCATION_PERCENTAGE, b.SAVINGS as BENEFICIARY_SAVINGS from T_ACCOUNT a, T_ACCOUNT_BENEFICIARY b, T_ACCOUNT_CREDIT_CARD c where ID = b.ACCOUNT_ID and ID = c.ACCOUNT_ID and c.NUMBER = ?"; + return jdbcTemplate.query(sql, accountExtractor, creditCardNumber); + } + + public void updateBeneficiaries(Account account) { + String sql = "update T_ACCOUNT_BENEFICIARY SET SAVINGS = ? where ACCOUNT_ID = ? and NAME = ?"; + for (Beneficiary b : account.getBeneficiaries()) { + jdbcTemplate.update(sql, b.getSavings().asBigDecimal(), account.getEntityId(), b.getName()); + } + } + + /** + * Map the rows returned from the join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY to a fully-reconstituted Account + * aggregate. + * + * @param rs the set of rows returned from the query + * @return the mapped Account aggregate + * @throws SQLException an exception occurred extracting data from the result set + */ + private Account mapAccount(ResultSet rs) throws SQLException { + Account account = null; + while (rs.next()) { + if (account == null) { + String number = rs.getString("ACCOUNT_NUMBER"); + String name = rs.getString("ACCOUNT_NAME"); + account = new Account(number, name); + // set internal entity identifier (primary key) + account.setEntityId(rs.getLong("ID")); + } + account.restoreBeneficiary(mapBeneficiary(rs)); + } + if (account == null) { + // no rows returned - throw an empty result exception + throw new EmptyResultDataAccessException(1); + } + return account; + } + + /** + * Maps the beneficiary columns in a single row to an AllocatedBeneficiary object. + * + * @param rs the result set with its cursor positioned at the current row + * @return an allocated beneficiary + * @throws SQLException an exception occurred extracting data from the result set + */ + private Beneficiary mapBeneficiary(ResultSet rs) throws SQLException { + String name = rs.getString("BENEFICIARY_NAME"); + MonetaryAmount savings = MonetaryAmount.valueOf(rs.getString("BENEFICIARY_SAVINGS")); + Percentage allocationPercentage = Percentage.valueOf(rs.getString("BENEFICIARY_ALLOCATION_PERCENTAGE")); + return new Beneficiary(name, allocationPercentage, savings); + } + + private class AccountExtractor implements ResultSetExtractor { + + public Account extractData(ResultSet rs) throws SQLException, DataAccessException { + return mapAccount(rs); + } + + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/account/package.html b/lab/28-transactions-solution/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/package.html b/lab/28-transactions-solution/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java b/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java new file mode 100644 index 0000000..b7d6d74 --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java @@ -0,0 +1,20 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * Determines if benefit is available for an account for dining. + * + * A value object. A strategy. Scoped by the Resturant aggregate. + */ +public interface BenefitAvailabilityPolicy { + + /** + * Calculates if an account is eligible to receive benefits for a dining. + * @param account the account of the member who dined + * @param dining the dining event + * @return benefit availability status + */ + boolean isBenefitAvailableFor(Account account, Dining dining); +} diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java b/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java new file mode 100644 index 0000000..f4cff67 --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java @@ -0,0 +1,117 @@ +package rewards.internal.restaurant; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.Percentage; + +/** + * Loads restaurants from a data source using the JDBC API. + */ +public class JdbcRestaurantRepository implements RestaurantRepository { + + private JdbcTemplate jdbcTemplate; + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + */ + private final RowMapper rowMapper = new RestaurantRowMapper(); + + public void setDataSource(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + String sql = "select MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY from T_RESTAURANT where MERCHANT_NUMBER = ?"; + return jdbcTemplate.queryForObject(sql, rowMapper, merchantNumber); + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + * + * @param rs the result set with its cursor positioned at the current row + */ + private Restaurant mapRestaurant(ResultSet rs) throws SQLException { + // get the row column data + String name = rs.getString("NAME"); + String number = rs.getString("MERCHANT_NUMBER"); + Percentage benefitPercentage = Percentage.valueOf(rs.getString("BENEFIT_PERCENTAGE")); + // map to the object + Restaurant restaurant = new Restaurant(number, name); + restaurant.setBenefitPercentage(benefitPercentage); + restaurant.setBenefitAvailabilityPolicy(mapBenefitAvailabilityPolicy(rs)); + return restaurant; + } + + /** + * Helper method that maps benefit availability policy data in the ResultSet to a fully-configured + * {@link BenefitAvailabilityPolicy} object. The key column is 'BENEFIT_AVAILABILITY_POLICY', which is a + * discriminator column containing a string code that identifies the type of policy. Currently supported types are: + * 'A' for 'always available' and 'N' for 'never available'. + * + *

+ * More types could be added easily by enhancing this method. For example, 'W' for 'Weekdays only' or 'M' for 'Max + * Rewards per Month'. Some of these types might require additional database column values to be configured, for + * example a 'MAX_REWARDS_PER_MONTH' data column. + * + * @param rs the result set used to map the policy object from database column values + * @return the matching benefit availability policy + * @throws IllegalArgumentException if the mapping could not be performed + */ + private BenefitAvailabilityPolicy mapBenefitAvailabilityPolicy(ResultSet rs) throws SQLException { + String policyCode = rs.getString("BENEFIT_AVAILABILITY_POLICY"); + if ("A".equals(policyCode)) { + return AlwaysAvailable.INSTANCE; + } else if ("N".equals(policyCode)) { + return NeverAvailable.INSTANCE; + } else { + throw new IllegalArgumentException("Not a supported policy code " + policyCode); + } + } + + /** + * Returns true indicating benefit is always available. + */ + static class AlwaysAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new AlwaysAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + + public String toString() { + return "alwaysAvailable"; + } + } + + /** + * Returns false indicating benefit is never available. + */ + static class NeverAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new NeverAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return false; + } + + public String toString() { + return "neverAvailable"; + } + } + + private class RestaurantRowMapper implements RowMapper { + + public Restaurant mapRow(ResultSet rs, int rowNum) throws SQLException { + return mapRestaurant(rs); + } + + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..2e73e57 --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,102 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on an availability policy and a + * benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + private BenefitAvailabilityPolicy benefitAvailabilityPolicy; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Sets the policy that determines if a dining by an account at this restaurant is eligible for benefit. + * @param benefitAvailabilityPolicy the benefit availability policy + */ + public void setBenefitAvailabilityPolicy(BenefitAvailabilityPolicy benefitAvailabilityPolicy) { + this.benefitAvailabilityPolicy = benefitAvailabilityPolicy; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Returns this restaurant's benefit availability policy. + */ + public BenefitAvailabilityPolicy getBenefitAvailabilityPolicy() { + return benefitAvailabilityPolicy; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + if (benefitAvailabilityPolicy.isBenefitAvailableFor(account, dining)) { + return dining.getAmount().multiplyBy(benefitPercentage); + } else { + return MonetaryAmount.zero(); + } + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage + + ", benefitAvailabilityPolicy = " + benefitAvailabilityPolicy; + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/package.html b/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/28-transactions-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..e5ae22b --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,38 @@ +package rewards.internal.reward; + +import javax.sql.DataSource; + +import org.springframework.jdbc.core.JdbcTemplate; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import common.datetime.SimpleDate; + +/** + * JDBC implementation of a reward repository that records the result of a reward transaction by inserting a reward + * confirmation record. + */ +public class JdbcRewardRepository implements RewardRepository { + + private JdbcTemplate jdbcTemplate; + + public void setDataSource(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + String confirmationNumber = nextConfirmationNumber(); + jdbcTemplate.update(sql, confirmationNumber, contribution.getAmount().asBigDecimal(), + SimpleDate.today().asDate(), contribution.getAccountNumber(), dining.getMerchantNumber(), + dining.getDate().asDate(), dining.getAmount().asBigDecimal()); + return new RewardConfirmation(confirmationNumber, contribution); + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + return jdbcTemplate.queryForObject(sql, String.class); + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/28-transactions-solution/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/main/java/rewards/internal/reward/package.html b/lab/28-transactions-solution/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/28-transactions-solution/src/main/java/rewards/package.html b/lab/28-transactions-solution/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/28-transactions-solution/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/28-transactions-solution/src/test/java/rewards/RewardNetworkPropagationTests.java b/lab/28-transactions-solution/src/test/java/rewards/RewardNetworkPropagationTests.java new file mode 100644 index 0000000..a5db0ef --- /dev/null +++ b/lab/28-transactions-solution/src/test/java/rewards/RewardNetworkPropagationTests.java @@ -0,0 +1,57 @@ +package rewards; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.DefaultTransactionDefinition; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * A system test that demonstrates how propagation settings affect transactional execution. + */ +@SpringJUnitConfig(classes = {SystemTestRequiresNewConfig.class}) +public class RewardNetworkPropagationTests { + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + /** + * A template to use for test verification + */ + private JdbcTemplate template; + + /** + * Manages transaction manually + */ + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + public void initJdbcTemplate(DataSource dataSource) { + this.template = new JdbcTemplate(dataSource); + } + + @Test + public void testPropagation() { + // Open a transaction for testing + TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + rewardNetwork.rewardAccountFor(dining); + + // Rollback the transaction test transaction + transactionManager.rollback(status); + + String sql = "select SAVINGS from T_ACCOUNT_BENEFICIARY where NAME = ?"; + assertEquals(Double.valueOf(4.00), template.queryForObject(sql, Double.class, "Annabelle")); + assertEquals(Double.valueOf(4.00), template.queryForObject(sql, Double.class, "Corgan")); + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/test/java/rewards/RewardNetworkSideEffectTests.java b/lab/28-transactions-solution/src/test/java/rewards/RewardNetworkSideEffectTests.java new file mode 100644 index 0000000..34aa86d --- /dev/null +++ b/lab/28-transactions-solution/src/test/java/rewards/RewardNetworkSideEffectTests.java @@ -0,0 +1,87 @@ +package rewards; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.annotation.Transactional; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * A system test that demonstrates how the effects of a given test can affect + * all tests that follow. JUnit makes no guarantee about the order that tests + * run in, so we force tests to run in method name order. + */ +@SpringJUnitConfig(classes = {SystemTestConfig.class}) +@Transactional +@TestMethodOrder(MethodOrderer.MethodName.class) +public class RewardNetworkSideEffectTests { + + private static final String SAVINGS_SQL = "select SAVINGS from T_ACCOUNT_BENEFICIARY where NAME = ?"; + + /** + * Amount of money in Annabelle's savings account before running the test + * methods + */ + private static Double annabelleInitialSavings; + + /** + * Amount of money in Corgan's savings account before running the test + * methods + */ + private static Double corganInitialSavings; + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + /** + * A template to use for test verification + */ + private JdbcTemplate jdbcTemplate; + + @Autowired + public void initJdbcTemplate(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + /** + * Determine the initial savings: note that we're doing this only once for + * all tests, so if a test changes the savings and commits the next test + * method might be affected! + */ + @BeforeEach + public void determineInitialSavings() { + if (annabelleInitialSavings == null) { + annabelleInitialSavings = jdbcTemplate.queryForObject(SAVINGS_SQL, Double.class, "Annabelle"); + corganInitialSavings = jdbcTemplate.queryForObject(SAVINGS_SQL, Double.class, "Corgan"); + } + } + + private void runTest() { + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + rewardNetwork.rewardAccountFor(dining); + assertEquals(Double.valueOf(annabelleInitialSavings + 4.00d), + jdbcTemplate.queryForObject(SAVINGS_SQL, Double.class, "Annabelle")); + assertEquals(Double.valueOf(corganInitialSavings + 4.00d), + jdbcTemplate.queryForObject(SAVINGS_SQL, Double.class, "Corgan")); + } + + @Test + public void testCollision1stTime() { + runTest(); + } + + @Test + public void testCollision2ndTime() { + runTest(); + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/test/java/rewards/RewardNetworkTests.java b/lab/28-transactions-solution/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..656c0b5 --- /dev/null +++ b/lab/28-transactions-solution/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,86 @@ +package rewards; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import common.money.MonetaryAmount; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A system test that verifies the components of the RewardNetwork application + * work together to reward for dining successfully. Uses Spring to bootstrap the + * application for use in a test environment. + */ +@SpringJUnitConfig(classes = {SystemTestConfig.class}) +public class RewardNetworkTests { + + /** + * The test entry point: + */ + @Autowired + RewardNetwork rewardNetwork; + + @Autowired + DataSource dataSource; + + JdbcTemplate jdbcTemplate; + + @BeforeEach + public void setup() { + jdbcTemplate = new JdbcTemplate(dataSource); + + // Using Logback for logging. + // Enable DEBUG logging so we can see the transactions + Logger jdbcLogger = (Logger) LoggerFactory + .getLogger("org.springframework.jdbc.datasource.DataSourceTransactionManager"); + jdbcLogger.setLevel(Level.DEBUG); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card + // '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // Check the DB to see if the Reward is actually on the table: + String sql = "SELECT COUNT(*) FROM T_REWARD WHERE CONFIRMATION_NUMBER = ? AND REWARD_AMOUNT = ?"; + int count = jdbcTemplate.queryForObject(sql, Integer.class, confirmation.getConfirmationNumber(), + confirmation.getAccountContribution().getAmount().asBigDecimal()); + + assertEquals(1, count); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 + // distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/test/java/rewards/SystemTestConfig.java b/lab/28-transactions-solution/src/test/java/rewards/SystemTestConfig.java new file mode 100644 index 0000000..d3c3461 --- /dev/null +++ b/lab/28-transactions-solution/src/test/java/rewards/SystemTestConfig.java @@ -0,0 +1,39 @@ +package rewards; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.transaction.PlatformTransactionManager; + +import config.RewardsConfig; + + +@Configuration +@Import(RewardsConfig.class) +public class SystemTestConfig { + + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ + @Bean + public DataSource dataSource(){ + return + (new EmbeddedDatabaseBuilder()) + .addScript("classpath:rewards/testdb/schema.sql") + .addScript("classpath:rewards/testdb/data.sql") + .build(); + } + + + @Bean + public PlatformTransactionManager transactionManager(){ + return new DataSourceTransactionManager(dataSource()); + } + +} diff --git a/lab/28-transactions-solution/src/test/java/rewards/SystemTestRequiresNewConfig.java b/lab/28-transactions-solution/src/test/java/rewards/SystemTestRequiresNewConfig.java new file mode 100644 index 0000000..75a8b6b --- /dev/null +++ b/lab/28-transactions-solution/src/test/java/rewards/SystemTestRequiresNewConfig.java @@ -0,0 +1,28 @@ +package rewards; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import rewards.internal.RewardNetworkImplRequiresNew; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + + +@Configuration +@Import(SystemTestConfig.class) +public class SystemTestRequiresNewConfig { + + @Bean + public RewardNetwork rewardNetwork( + AccountRepository accountRepository, + RestaurantRepository restaurantRepository, + RewardRepository rewardRepository ) { + return new RewardNetworkImplRequiresNew( + accountRepository, + restaurantRepository, + rewardRepository); + } + +} diff --git a/lab/28-transactions-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/28-transactions-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..98b7353 --- /dev/null +++ b/lab/28-transactions-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,72 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/test/java/rewards/internal/StubAccountRepository.java b/lab/28-transactions-solution/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..b926be2 --- /dev/null +++ b/lab/28-transactions-solution/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,43 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new EmptyResultDataAccessException(1); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/28-transactions-solution/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..418516d --- /dev/null +++ b/lab/28-transactions-solution/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,53 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.Dining; +import rewards.internal.account.Account; +import rewards.internal.restaurant.BenefitAvailabilityPolicy; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new AlwaysReturnsTrue()); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } + + /** + * A simple "dummy" benefit availability policy that always returns true. Only useful for testing--a real + * availability policy might consider many factors such as the day of week of the dining, or the account's reward + * history for the current month. + */ + private static class AlwaysReturnsTrue implements BenefitAvailabilityPolicy { + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/test/java/rewards/internal/StubRewardRepository.java b/lab/28-transactions-solution/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..2487aca --- /dev/null +++ b/lab/28-transactions-solution/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import java.util.Random; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/test/java/rewards/internal/account/AccountTests.java b/lab/28-transactions-solution/src/test/java/rewards/internal/account/AccountTests.java new file mode 100644 index 0000000..4075654 --- /dev/null +++ b/lab/28-transactions-solution/src/test/java/rewards/internal/account/AccountTests.java @@ -0,0 +1,57 @@ +package rewards.internal.account; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for the Account class that verify Account behavior works in isolation. + */ +public class AccountTests { + + private final Account account = new Account("1", "Keith and Keri Donald"); + + @Test + public void accountIsValid() { + // setup account with a valid set of beneficiaries to prepare for testing + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + assertTrue(account.isValid()); + } + + @Test + public void accountIsInvalidWithNoBeneficiaries() { + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreOver100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreUnder100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("25%")); + assertFalse(account.isValid()); + } + + @Test + public void makeContribution() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("100.00")); + assertEquals(contribution.getAmount(), MonetaryAmount.valueOf("100.00")); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java b/lab/28-transactions-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java new file mode 100644 index 0000000..4326571 --- /dev/null +++ b/lab/28-transactions-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java @@ -0,0 +1,96 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC account repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcAccountRepositoryTests { + + private JdbcAccountRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + repository = new JdbcAccountRepository(); + repository.setDataSource(dataSource); + } + + @Test + public void testFindAccountByCreditCard() { + Account account = repository.findByCreditCard("1234123412341234"); + // assert the returned account contains what you expect given the state of the database + assertNotNull(account, "account should never be null"); + assertEquals(Long.valueOf(0), account.getEntityId(), "wrong entity id"); + assertEquals("123456789", account.getNumber(), "wrong account number"); + assertEquals("Keith and Keri Donald", account.getName(), "wrong name"); + assertEquals(2, account.getBeneficiaries().size(), "wrong beneficiary collection size"); + + Beneficiary b1 = account.getBeneficiary("Annabelle"); + assertNotNull(b1, "Annabelle should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b1.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b1.getAllocationPercentage(), "wrong allocation percentage"); + + Beneficiary b2 = account.getBeneficiary("Corgan"); + assertNotNull(b2, "Corgan should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b2.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b2.getAllocationPercentage(), "wrong allocation percentage"); + } + + @Test + public void testFindAccountByCreditCardNoAccount() { + assertThrows(EmptyResultDataAccessException.class, () -> { + repository.findByCreditCard("bogus"); + }); + } + + @Test + public void testUpdateBeneficiaries() throws SQLException { + Account account = repository.findByCreditCard("1234123412341234"); + account.makeContribution(MonetaryAmount.valueOf("8.00")); + repository.updateBeneficiaries(account); + verifyBeneficiaryTableUpdated(); + } + + private void verifyBeneficiaryTableUpdated() throws SQLException { + String sql = "select SAVINGS from T_ACCOUNT_BENEFICIARY where NAME = ? and ACCOUNT_ID = ?"; + PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql); + + // assert Annabelle has $4.00 savings now + stmt.setString(1, "Annabelle"); + stmt.setLong(2, 0L); + ResultSet rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + + // assert Corgan has $4.00 savings now + stmt.setString(1, "Corgan"); + stmt.setLong(2, 0L); + rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/28-transactions-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java b/lab/28-transactions-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java new file mode 100644 index 0000000..565dc1b --- /dev/null +++ b/lab/28-transactions-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java @@ -0,0 +1,52 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC restaurant repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRestaurantRepositoryTests { + + private JdbcRestaurantRepository repository; + + @BeforeEach + public void setUp() { + repository = new JdbcRestaurantRepository(); + repository.setDataSource(createTestDataSource()); + } + + @Test + public void testFindRestaurantByMerchantNumber() { + Restaurant restaurant = repository.findByMerchantNumber("1234567890"); + assertNotNull(restaurant, "the restaurant should never be null"); + assertEquals("1234567890", restaurant.getNumber(), "the merchant number is wrong"); + assertEquals("AppleBees", restaurant.getName(), "the name is wrong"); + assertEquals(Percentage.valueOf("8%"), restaurant.getBenefitPercentage(), "the benefitPercentage is wrong"); + assertEquals(JdbcRestaurantRepository.AlwaysAvailable.INSTANCE, + restaurant.getBenefitAvailabilityPolicy(), "the benefit availability policy is wrong"); + } + + @Test + public void testFindRestaurantByBogusMerchantNumber() { + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("bogus"); + }); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/28-transactions-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java b/lab/28-transactions-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java new file mode 100644 index 0000000..93c2496 --- /dev/null +++ b/lab/28-transactions-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java @@ -0,0 +1,71 @@ +package rewards.internal.restaurant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for exercising the behavior of the Restaurant aggregate entity. A restaurant calculates a benefit to award + * to an account for dining based on an availability policy and benefit percentage. + */ +public class RestaurantTests { + + private Restaurant restaurant; + + private Account account; + + private Dining dining; + + @BeforeEach + public void setUp() { + // configure the restaurant, the object being tested + restaurant = new Restaurant("1234567890", "AppleBee's"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(true)); + // configure supporting objects needed by the restaurant + account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle"); + dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + } + + @Test + public void testCalcuateBenefitFor() { + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert 8.00 eligible for reward + assertEquals(MonetaryAmount.valueOf("8.00"), benefit); + } + + @Test + public void testNoBenefitAvailable() { + // configure stub that always returns false + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(false)); + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert zero eligible for reward + assertEquals(MonetaryAmount.valueOf("0.00"), benefit); + } + + /** + * A simple "dummy" benefit availability policy containing a single flag used to determine if benefit is available. + * Only useful for testing--a real availability policy might consider many factors such as the day of week of the + * dining, or the account's reward history for the current month. + */ + private static class StubBenefitAvailibilityPolicy implements BenefitAvailabilityPolicy { + + private final boolean isBenefitAvailable; + + public StubBenefitAvailibilityPolicy(boolean isBenefitAvailable) { + this.isBenefitAvailable = isBenefitAvailable; + } + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return isBenefitAvailable; + } + } +} \ No newline at end of file diff --git a/lab/28-transactions-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java b/lab/28-transactions-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java new file mode 100644 index 0000000..5af5f02 --- /dev/null +++ b/lab/28-transactions-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java @@ -0,0 +1,92 @@ +package rewards.internal.reward; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.math.BigDecimal; +import java.sql.SQLException; +import java.util.Map; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.Account; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Tests the JDBC reward repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRewardRepositoryTests { + + private JdbcRewardRepository repository; + + private DataSource dataSource; + + private JdbcTemplate jdbcTemplate; + + @BeforeEach + public void setUp() { + repository = new JdbcRewardRepository(); + dataSource = createTestDataSource(); + repository.setDataSource(dataSource); + jdbcTemplate = new JdbcTemplate(dataSource); + } + + @Test + public void testCreateReward() throws SQLException { + Dining dining = Dining.createDining("100.00", "1234123412341234", "0123456789"); + + Account account = new Account("1", "Keith and Keri Donald"); + account.setEntityId(0L); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("8.00")); + RewardConfirmation confirmation = repository.confirmReward(contribution, dining); + assertNotNull(confirmation, "confirmation should not be null"); + assertNotNull(confirmation.getConfirmationNumber(), "confirmation number should not be null"); + assertEquals(contribution, confirmation.getAccountContribution(), "wrong contribution object"); + verifyRewardInserted(confirmation, dining); + } + + private void verifyRewardInserted(RewardConfirmation confirmation, Dining dining) { + assertEquals(1, getRewardCount()); + String sql = "select * from T_REWARD where CONFIRMATION_NUMBER = ?"; + Map values = jdbcTemplate.queryForMap(sql, confirmation.getConfirmationNumber()); + verifyInsertedValues(confirmation, dining, values); + } + + private void verifyInsertedValues(RewardConfirmation confirmation, Dining dining, Map values) { + assertEquals(confirmation.getAccountContribution().getAmount(), new MonetaryAmount((BigDecimal) values + .get("REWARD_AMOUNT"))); + assertEquals(SimpleDate.today().asDate(), values.get("REWARD_DATE")); + assertEquals(confirmation.getAccountContribution().getAccountNumber(), values.get("ACCOUNT_NUMBER")); + assertEquals(dining.getAmount(), new MonetaryAmount((BigDecimal) values.get("DINING_AMOUNT"))); + assertEquals(dining.getMerchantNumber(), values.get("DINING_MERCHANT_NUMBER")); + assertEquals(SimpleDate.today().asDate(), values.get("DINING_DATE")); + } + + private int getRewardCount() { + String sql = "select count(*) from T_REWARD"; + return jdbcTemplate.queryForObject(sql, Integer.class); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/28-transactions/build.gradle b/lab/28-transactions/build.gradle new file mode 100644 index 0000000..2618ecd --- /dev/null +++ b/lab/28-transactions/build.gradle @@ -0,0 +1,10 @@ +dependencies { + implementation project(':00-rewards-common') +} + +test { + exclude '**/RewardNetworkTests.class' + exclude '**/RewardNetworkPropagationTests.class' + exclude '**/RewardNetworkSideEffectTests.class' +} + diff --git a/lab/28-transactions/pom.xml b/lab/28-transactions/pom.xml new file mode 100644 index 0000000..3414bbb --- /dev/null +++ b/lab/28-transactions/pom.xml @@ -0,0 +1,37 @@ + + + 4.0.0 + 28-transactions + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/RewardNetworkTests.java + **/RewardNetworkSideEffectTests.java + **/RewardNetworkPropagationTests.java + + + + + + diff --git a/lab/28-transactions/src/main/java/config/RewardsConfig.java b/lab/28-transactions/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..99cdf2f --- /dev/null +++ b/lab/28-transactions/src/main/java/config/RewardsConfig.java @@ -0,0 +1,55 @@ +package config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import rewards.RewardNetwork; +import rewards.internal.RewardNetworkImpl; +import rewards.internal.account.AccountRepository; +import rewards.internal.account.JdbcAccountRepository; +import rewards.internal.restaurant.JdbcRestaurantRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.JdbcRewardRepository; +import rewards.internal.reward.RewardRepository; + +import javax.sql.DataSource; + + +// TODO-03: Add an annotation to enable Spring transaction + +@Configuration +public class RewardsConfig { + + @Autowired + DataSource dataSource; + + @Bean + public RewardNetwork rewardNetwork(){ + return new RewardNetworkImpl( + accountRepository(), + restaurantRepository(), + rewardRepository()); + } + + @Bean + public AccountRepository accountRepository(){ + JdbcAccountRepository repository = new JdbcAccountRepository(); + repository.setDataSource(dataSource); + return repository; + } + + @Bean + public RestaurantRepository restaurantRepository(){ + JdbcRestaurantRepository repository = new JdbcRestaurantRepository(); + repository.setDataSource(dataSource); + return repository; + } + + @Bean + public RewardRepository rewardRepository(){ + JdbcRewardRepository repository = new JdbcRewardRepository(); + repository.setDataSource(dataSource); + return repository; + } + +} diff --git a/lab/28-transactions/src/main/java/rewards/AccountContribution.java b/lab/28-transactions/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/main/java/rewards/Dining.java b/lab/28-transactions/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/main/java/rewards/RewardConfirmation.java b/lab/28-transactions/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/main/java/rewards/RewardNetwork.java b/lab/28-transactions/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/28-transactions/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/28-transactions/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..b04f702 --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,65 @@ +package rewards.internal; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer + * service responsible for coordinating with the domain-layer to carry out + * the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + * + * TODO-00: In this lab, you are going to exercise the following: + * - Enabling Spring Transaction + * - Adding transactional behavior to a method + * - Exercising transaction propagation + * - Exercising transactional behavior in test + */ +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + // TODO-06: Modify the transactional attributes of the rewardAccountFor() method below. + // Switch the propagation level to require a NEW transaction whenever invoked. + + // TODO-01: Annotate this method as needing transactional behavior + // Make sure to use the annotation from Spring not from Jakarta EE. + + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.confirmReward(contribution, dining); + } +} diff --git a/lab/28-transactions/src/main/java/rewards/internal/account/Account.java b/lab/28-transactions/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..84463f0 --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,159 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + try { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } catch (IllegalArgumentException e) { + // total would have been over 100% - return invalid + return false; + } + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. Callers should not attempt to hold on or modify the returned set. + * This method should only be used transitively; for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Returns a single account beneficiary. Callers should not attempt to hold on or modify the returned object. This + * method should only be used transitively; for example, called to facilitate reporting or testing. + * @param name the name of the beneficiary e.g "Annabelle" + * @return the beneficiary object + */ + public Beneficiary getBeneficiary(String name) { + for (Beneficiary b : beneficiaries) { + if (b.getName().equals(name)) { + return b; + } + } + throw new IllegalArgumentException("No such beneficiary with name '" + name + "'"); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/main/java/rewards/internal/account/AccountRepository.java b/lab/28-transactions/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..16c6079 --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/28-transactions/src/main/java/rewards/internal/account/Beneficiary.java b/lab/28-transactions/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/main/java/rewards/internal/account/JdbcAccountRepository.java b/lab/28-transactions/src/main/java/rewards/internal/account/JdbcAccountRepository.java new file mode 100644 index 0000000..6a186e3 --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/account/JdbcAccountRepository.java @@ -0,0 +1,92 @@ +package rewards.internal.account; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.springframework.dao.DataAccessException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.ResultSetExtractor; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Loads accounts from a data source using the JDBC API. + */ +public class JdbcAccountRepository implements AccountRepository { + + private JdbcTemplate jdbcTemplate; + + /** + * Extracts an Account object from rows returned from a join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY. + */ + private final ResultSetExtractor accountExtractor = new AccountExtractor(); + + public void setDataSource(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + public Account findByCreditCard(String creditCardNumber) { + String sql = "select a.ID as ID, a.NUMBER as ACCOUNT_NUMBER, a.NAME as ACCOUNT_NAME, c.NUMBER as CREDIT_CARD_NUMBER, b.NAME as BENEFICIARY_NAME, b.ALLOCATION_PERCENTAGE as BENEFICIARY_ALLOCATION_PERCENTAGE, b.SAVINGS as BENEFICIARY_SAVINGS from T_ACCOUNT a, T_ACCOUNT_BENEFICIARY b, T_ACCOUNT_CREDIT_CARD c where ID = b.ACCOUNT_ID and ID = c.ACCOUNT_ID and c.NUMBER = ?"; + return jdbcTemplate.query(sql, accountExtractor, creditCardNumber); + } + + public void updateBeneficiaries(Account account) { + String sql = "update T_ACCOUNT_BENEFICIARY SET SAVINGS = ? where ACCOUNT_ID = ? and NAME = ?"; + for (Beneficiary b : account.getBeneficiaries()) { + jdbcTemplate.update(sql, b.getSavings().asBigDecimal(), account.getEntityId(), b.getName()); + } + } + + /** + * Map the rows returned from the join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY to a fully-reconstituted Account + * aggregate. + * + * @param rs the set of rows returned from the query + * @return the mapped Account aggregate + * @throws SQLException an exception occurred extracting data from the result set + */ + private Account mapAccount(ResultSet rs) throws SQLException { + Account account = null; + while (rs.next()) { + if (account == null) { + String number = rs.getString("ACCOUNT_NUMBER"); + String name = rs.getString("ACCOUNT_NAME"); + account = new Account(number, name); + // set internal entity identifier (primary key) + account.setEntityId(rs.getLong("ID")); + } + account.restoreBeneficiary(mapBeneficiary(rs)); + } + if (account == null) { + // no rows returned - throw an empty result exception + throw new EmptyResultDataAccessException(1); + } + return account; + } + + /** + * Maps the beneficiary columns in a single row to an AllocatedBeneficiary object. + * + * @param rs the result set with its cursor positioned at the current row + * @return an allocated beneficiary + * @throws SQLException an exception occurred extracting data from the result set + */ + private Beneficiary mapBeneficiary(ResultSet rs) throws SQLException { + String name = rs.getString("BENEFICIARY_NAME"); + MonetaryAmount savings = MonetaryAmount.valueOf(rs.getString("BENEFICIARY_SAVINGS")); + Percentage allocationPercentage = Percentage.valueOf(rs.getString("BENEFICIARY_ALLOCATION_PERCENTAGE")); + return new Beneficiary(name, allocationPercentage, savings); + } + + private class AccountExtractor implements ResultSetExtractor { + + public Account extractData(ResultSet rs) throws SQLException, DataAccessException { + return mapAccount(rs); + } + + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/main/java/rewards/internal/account/package.html b/lab/28-transactions/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/28-transactions/src/main/java/rewards/internal/package.html b/lab/28-transactions/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/28-transactions/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java b/lab/28-transactions/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java new file mode 100644 index 0000000..b7d6d74 --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java @@ -0,0 +1,20 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * Determines if benefit is available for an account for dining. + * + * A value object. A strategy. Scoped by the Resturant aggregate. + */ +public interface BenefitAvailabilityPolicy { + + /** + * Calculates if an account is eligible to receive benefits for a dining. + * @param account the account of the member who dined + * @param dining the dining event + * @return benefit availability status + */ + boolean isBenefitAvailableFor(Account account, Dining dining); +} diff --git a/lab/28-transactions/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java b/lab/28-transactions/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java new file mode 100644 index 0000000..f4cff67 --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java @@ -0,0 +1,117 @@ +package rewards.internal.restaurant; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import javax.sql.DataSource; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.Percentage; + +/** + * Loads restaurants from a data source using the JDBC API. + */ +public class JdbcRestaurantRepository implements RestaurantRepository { + + private JdbcTemplate jdbcTemplate; + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + */ + private final RowMapper rowMapper = new RestaurantRowMapper(); + + public void setDataSource(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + String sql = "select MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY from T_RESTAURANT where MERCHANT_NUMBER = ?"; + return jdbcTemplate.queryForObject(sql, rowMapper, merchantNumber); + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + * + * @param rs the result set with its cursor positioned at the current row + */ + private Restaurant mapRestaurant(ResultSet rs) throws SQLException { + // get the row column data + String name = rs.getString("NAME"); + String number = rs.getString("MERCHANT_NUMBER"); + Percentage benefitPercentage = Percentage.valueOf(rs.getString("BENEFIT_PERCENTAGE")); + // map to the object + Restaurant restaurant = new Restaurant(number, name); + restaurant.setBenefitPercentage(benefitPercentage); + restaurant.setBenefitAvailabilityPolicy(mapBenefitAvailabilityPolicy(rs)); + return restaurant; + } + + /** + * Helper method that maps benefit availability policy data in the ResultSet to a fully-configured + * {@link BenefitAvailabilityPolicy} object. The key column is 'BENEFIT_AVAILABILITY_POLICY', which is a + * discriminator column containing a string code that identifies the type of policy. Currently supported types are: + * 'A' for 'always available' and 'N' for 'never available'. + * + *

+ * More types could be added easily by enhancing this method. For example, 'W' for 'Weekdays only' or 'M' for 'Max + * Rewards per Month'. Some of these types might require additional database column values to be configured, for + * example a 'MAX_REWARDS_PER_MONTH' data column. + * + * @param rs the result set used to map the policy object from database column values + * @return the matching benefit availability policy + * @throws IllegalArgumentException if the mapping could not be performed + */ + private BenefitAvailabilityPolicy mapBenefitAvailabilityPolicy(ResultSet rs) throws SQLException { + String policyCode = rs.getString("BENEFIT_AVAILABILITY_POLICY"); + if ("A".equals(policyCode)) { + return AlwaysAvailable.INSTANCE; + } else if ("N".equals(policyCode)) { + return NeverAvailable.INSTANCE; + } else { + throw new IllegalArgumentException("Not a supported policy code " + policyCode); + } + } + + /** + * Returns true indicating benefit is always available. + */ + static class AlwaysAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new AlwaysAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + + public String toString() { + return "alwaysAvailable"; + } + } + + /** + * Returns false indicating benefit is never available. + */ + static class NeverAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new NeverAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return false; + } + + public String toString() { + return "neverAvailable"; + } + } + + private class RestaurantRowMapper implements RowMapper { + + public Restaurant mapRow(ResultSet rs, int rowNum) throws SQLException { + return mapRestaurant(rs); + } + + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/28-transactions/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..2e73e57 --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,102 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on an availability policy and a + * benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + private BenefitAvailabilityPolicy benefitAvailabilityPolicy; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Sets the policy that determines if a dining by an account at this restaurant is eligible for benefit. + * @param benefitAvailabilityPolicy the benefit availability policy + */ + public void setBenefitAvailabilityPolicy(BenefitAvailabilityPolicy benefitAvailabilityPolicy) { + this.benefitAvailabilityPolicy = benefitAvailabilityPolicy; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Returns this restaurant's benefit availability policy. + */ + public BenefitAvailabilityPolicy getBenefitAvailabilityPolicy() { + return benefitAvailabilityPolicy; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + if (benefitAvailabilityPolicy.isBenefitAvailableFor(account, dining)) { + return dining.getAmount().multiplyBy(benefitPercentage); + } else { + return MonetaryAmount.zero(); + } + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage + + ", benefitAvailabilityPolicy = " + benefitAvailabilityPolicy; + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/28-transactions/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/28-transactions/src/main/java/rewards/internal/restaurant/package.html b/lab/28-transactions/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/28-transactions/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/28-transactions/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..e5ae22b --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,38 @@ +package rewards.internal.reward; + +import javax.sql.DataSource; + +import org.springframework.jdbc.core.JdbcTemplate; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import common.datetime.SimpleDate; + +/** + * JDBC implementation of a reward repository that records the result of a reward transaction by inserting a reward + * confirmation record. + */ +public class JdbcRewardRepository implements RewardRepository { + + private JdbcTemplate jdbcTemplate; + + public void setDataSource(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + String confirmationNumber = nextConfirmationNumber(); + jdbcTemplate.update(sql, confirmationNumber, contribution.getAmount().asBigDecimal(), + SimpleDate.today().asDate(), contribution.getAccountNumber(), dining.getMerchantNumber(), + dining.getDate().asDate(), dining.getAmount().asBigDecimal()); + return new RewardConfirmation(confirmationNumber, contribution); + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + return jdbcTemplate.queryForObject(sql, String.class); + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/28-transactions/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/28-transactions/src/main/java/rewards/internal/reward/package.html b/lab/28-transactions/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/28-transactions/src/main/java/rewards/package.html b/lab/28-transactions/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/28-transactions/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/28-transactions/src/test/java/rewards/RewardNetworkPropagationTests.java b/lab/28-transactions/src/test/java/rewards/RewardNetworkPropagationTests.java new file mode 100644 index 0000000..6b1db0f --- /dev/null +++ b/lab/28-transactions/src/test/java/rewards/RewardNetworkPropagationTests.java @@ -0,0 +1,75 @@ +package rewards; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.DefaultTransactionDefinition; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * A system test that demonstrates how propagation settings affect transactional + * execution. + */ + +// TODO-05: Review and run the testPropagation() method below. +// +// It uses a transaction-manager to implement transactions manually +// and runs rewardAccountFor() within a transaction then performs a manual rollback. +// The assertions will succeed only if a database commit actually occurs. +// +// - Run this test, initially it will FAIL because the data has been rolled back. +// We are going to address this in the subsequent steps. + +// TODO-07: Re-run this test, it should now pass. +// - Think about why this test passes now. + +@SpringJUnitConfig(classes = {SystemTestConfig.class}) +public class RewardNetworkPropagationTests { + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + /** + * A template to use for test verification + */ + private JdbcTemplate template; + + /** + * Manages transaction manually + */ + @Autowired + private PlatformTransactionManager transactionManager; + + @Autowired + public void initJdbcTemplate(DataSource dataSource) { + this.template = new JdbcTemplate(dataSource); + } + + @Test + public void testPropagation() { + // Open a transaction for testing + TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); + + // Run the test - generate a reward + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + rewardNetwork.rewardAccountFor(dining); + + // Rollback the transaction started by this test + transactionManager.rollback(status); + + // Assert that a Reward has been saved to the database - for this to be true + // the RewardNetwork must run and commit its OWN transaction + String sql = "select SAVINGS from T_ACCOUNT_BENEFICIARY where NAME = ?"; + assertEquals(Double.valueOf(4.00), template.queryForObject(sql, Double.class, "Annabelle")); + assertEquals(Double.valueOf(4.00), template.queryForObject(sql, Double.class, "Corgan")); + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/test/java/rewards/RewardNetworkSideEffectTests.java b/lab/28-transactions/src/test/java/rewards/RewardNetworkSideEffectTests.java new file mode 100644 index 0000000..f20a226 --- /dev/null +++ b/lab/28-transactions/src/test/java/rewards/RewardNetworkSideEffectTests.java @@ -0,0 +1,98 @@ +package rewards; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * A system test that demonstrates how the effects of a given test can affect + * all tests that follow. + * + * JUnit makes no guarantee about the order that tests run in, so we force tests + * to run in method name order using @FixMethodOrder(MethodSorters.NAME_ASCENDING) + * in this particular testing scenario. (In general, you should not do this.) + * + * TODO-08: MAKE SURE to revert the propagation attribute back to + * REQUIRED in RewardNetworkImpl. + * + * TODO-09: Examine the @Test logic below. Note that committed results from the + * first test will invalidate the assertions in the second test. Run this test, + * at the class level so that both tests run it should fail. Do you know why? + * + * TODO-10: Add @Transactional on the class and re-run the test. It should pass. + * Do you know why? + */ +@SpringJUnitConfig(classes = {SystemTestConfig.class}) +@TestMethodOrder(MethodOrderer.MethodName.class) +public class RewardNetworkSideEffectTests { + + private static final String SAVINGS_SQL = "select SAVINGS from T_ACCOUNT_BENEFICIARY where NAME = ?"; + + /** + * Amount of money in Annabelle's savings account before running the test + * methods + */ + private static Double annabelleInitialSavings; + + /** + * Amount of money in Corgan's savings account before running the test + * methods + */ + private static Double corganInitialSavings; + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + /** + * A template to use for test verification + */ + private JdbcTemplate jdbcTemplate; + + @Autowired + public void initJdbcTemplate(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + /** + * Determine the initial savings: note that we're doing this only once for + * all tests, so if a test changes the savings and commits the next test + * method might be affected! + */ + @BeforeEach + public void determineInitialSavings() { + if (annabelleInitialSavings == null) { + annabelleInitialSavings = jdbcTemplate.queryForObject(SAVINGS_SQL, Double.class, "Annabelle"); + corganInitialSavings = jdbcTemplate.queryForObject(SAVINGS_SQL, Double.class, "Corgan"); + } + } + + private void runTest() { + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + rewardNetwork.rewardAccountFor(dining); + assertEquals(Double.valueOf(annabelleInitialSavings + 4.00d), + jdbcTemplate.queryForObject(SAVINGS_SQL, Double.class, "Annabelle")); + assertEquals(Double.valueOf(corganInitialSavings + 4.00d), + jdbcTemplate.queryForObject(SAVINGS_SQL, Double.class, "Corgan")); + } + + @Test + public void testCollision1stTime() { + runTest(); + } + + @Test + public void testCollision2ndTime() { + runTest(); + } +} diff --git a/lab/28-transactions/src/test/java/rewards/RewardNetworkTests.java b/lab/28-transactions/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..e8c9b37 --- /dev/null +++ b/lab/28-transactions/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,93 @@ +package rewards; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import common.money.MonetaryAmount; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A system test that verifies the components of the RewardNetwork application + * work together to reward for dining successfully. Uses Spring to bootstrap the + * application for use in a test environment. + * + * TODO-04: Save all work, run this RewardNetworkTests below. It should pass. + * (If you are using Gradle, remove all exclude statements + * from the build.gradle file before running the test.) + * - Notice that we have enabled DEBUG logging in setup() below. + * - Check the logging output. Is only ONE connection being used? + */ +@SpringJUnitConfig(classes = {SystemTestConfig.class}) +public class RewardNetworkTests { + + /** + * The test entry point: + */ + @Autowired + RewardNetwork rewardNetwork; + + @Autowired + DataSource dataSource; + + JdbcTemplate jdbcTemplate; + + @BeforeEach + public void setup() { + jdbcTemplate = new JdbcTemplate(dataSource); + + // Using Logback for logging. + // Enable DEBUG logging so we can see the transactions + Logger jdbcLogger = (Logger) LoggerFactory + .getLogger("org.springframework.jdbc.datasource.DataSourceTransactionManager"); + jdbcLogger.setLevel(Level.DEBUG); + + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card + // '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // Check the DB to see if the Reward is actually on the table: + String sql = "SELECT COUNT(*) FROM T_REWARD WHERE CONFIRMATION_NUMBER = ? AND REWARD_AMOUNT = ?"; + int count = jdbcTemplate.queryForObject(sql, Integer.class, confirmation.getConfirmationNumber(), + confirmation.getAccountContribution().getAmount().asBigDecimal()); + + assertEquals(1, count); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 + // distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/test/java/rewards/SystemTestConfig.java b/lab/28-transactions/src/test/java/rewards/SystemTestConfig.java new file mode 100644 index 0000000..3334eee --- /dev/null +++ b/lab/28-transactions/src/test/java/rewards/SystemTestConfig.java @@ -0,0 +1,36 @@ +package rewards; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import config.RewardsConfig; + + +@Configuration +@Import(RewardsConfig.class) +public class SystemTestConfig { + + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ + @Bean + public DataSource dataSource(){ + return + (new EmbeddedDatabaseBuilder()) // + .addScript("classpath:rewards/testdb/schema.sql") // + .addScript("classpath:rewards/testdb/data.sql") // + .build(); + } + + + // TODO-02: Define a bean named 'transactionManager' that configures a + // DataSourceTransactionManager. + // How does it know which dataSource to manage? + +} diff --git a/lab/28-transactions/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/28-transactions/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..98b7353 --- /dev/null +++ b/lab/28-transactions/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,72 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/test/java/rewards/internal/StubAccountRepository.java b/lab/28-transactions/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..64ef9c3 --- /dev/null +++ b/lab/28-transactions/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,44 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + + throw new EmptyResultDataAccessException(1); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/28-transactions/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..418516d --- /dev/null +++ b/lab/28-transactions/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,53 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.dao.EmptyResultDataAccessException; + +import rewards.Dining; +import rewards.internal.account.Account; +import rewards.internal.restaurant.BenefitAvailabilityPolicy; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new AlwaysReturnsTrue()); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } + + /** + * A simple "dummy" benefit availability policy that always returns true. Only useful for testing--a real + * availability policy might consider many factors such as the day of week of the dining, or the account's reward + * history for the current month. + */ + private static class AlwaysReturnsTrue implements BenefitAvailabilityPolicy { + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/test/java/rewards/internal/StubRewardRepository.java b/lab/28-transactions/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..2487aca --- /dev/null +++ b/lab/28-transactions/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import java.util.Random; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/test/java/rewards/internal/account/AccountTests.java b/lab/28-transactions/src/test/java/rewards/internal/account/AccountTests.java new file mode 100644 index 0000000..4075654 --- /dev/null +++ b/lab/28-transactions/src/test/java/rewards/internal/account/AccountTests.java @@ -0,0 +1,57 @@ +package rewards.internal.account; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for the Account class that verify Account behavior works in isolation. + */ +public class AccountTests { + + private final Account account = new Account("1", "Keith and Keri Donald"); + + @Test + public void accountIsValid() { + // setup account with a valid set of beneficiaries to prepare for testing + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + assertTrue(account.isValid()); + } + + @Test + public void accountIsInvalidWithNoBeneficiaries() { + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreOver100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreUnder100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("25%")); + assertFalse(account.isValid()); + } + + @Test + public void makeContribution() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("100.00")); + assertEquals(contribution.getAmount(), MonetaryAmount.valueOf("100.00")); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java b/lab/28-transactions/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java new file mode 100644 index 0000000..4326571 --- /dev/null +++ b/lab/28-transactions/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java @@ -0,0 +1,96 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC account repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcAccountRepositoryTests { + + private JdbcAccountRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + repository = new JdbcAccountRepository(); + repository.setDataSource(dataSource); + } + + @Test + public void testFindAccountByCreditCard() { + Account account = repository.findByCreditCard("1234123412341234"); + // assert the returned account contains what you expect given the state of the database + assertNotNull(account, "account should never be null"); + assertEquals(Long.valueOf(0), account.getEntityId(), "wrong entity id"); + assertEquals("123456789", account.getNumber(), "wrong account number"); + assertEquals("Keith and Keri Donald", account.getName(), "wrong name"); + assertEquals(2, account.getBeneficiaries().size(), "wrong beneficiary collection size"); + + Beneficiary b1 = account.getBeneficiary("Annabelle"); + assertNotNull(b1, "Annabelle should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b1.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b1.getAllocationPercentage(), "wrong allocation percentage"); + + Beneficiary b2 = account.getBeneficiary("Corgan"); + assertNotNull(b2, "Corgan should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b2.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b2.getAllocationPercentage(), "wrong allocation percentage"); + } + + @Test + public void testFindAccountByCreditCardNoAccount() { + assertThrows(EmptyResultDataAccessException.class, () -> { + repository.findByCreditCard("bogus"); + }); + } + + @Test + public void testUpdateBeneficiaries() throws SQLException { + Account account = repository.findByCreditCard("1234123412341234"); + account.makeContribution(MonetaryAmount.valueOf("8.00")); + repository.updateBeneficiaries(account); + verifyBeneficiaryTableUpdated(); + } + + private void verifyBeneficiaryTableUpdated() throws SQLException { + String sql = "select SAVINGS from T_ACCOUNT_BENEFICIARY where NAME = ? and ACCOUNT_ID = ?"; + PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql); + + // assert Annabelle has $4.00 savings now + stmt.setString(1, "Annabelle"); + stmt.setLong(2, 0L); + ResultSet rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + + // assert Corgan has $4.00 savings now + stmt.setString(1, "Corgan"); + stmt.setLong(2, 0L); + rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/28-transactions/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java b/lab/28-transactions/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java new file mode 100644 index 0000000..a5f6400 --- /dev/null +++ b/lab/28-transactions/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java @@ -0,0 +1,52 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC restaurant repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRestaurantRepositoryTests { + + private JdbcRestaurantRepository repository; + + @BeforeEach + public void setUp() { + repository = new JdbcRestaurantRepository(); + repository.setDataSource(createTestDataSource()); + } + + @Test + public void testFindRestaurantByMerchantNumber() { + Restaurant restaurant = repository.findByMerchantNumber("1234567890"); + assertNotNull(restaurant, "the restaurant should never be null"); + assertEquals("1234567890", restaurant.getNumber(), "the merchant number is wrong"); + assertEquals("AppleBees", restaurant.getName(), "the name is wrong"); + assertEquals(Percentage.valueOf("8%"), restaurant.getBenefitPercentage(), "the benefitPercentage is wrong"); + assertEquals(JdbcRestaurantRepository.AlwaysAvailable.INSTANCE, + restaurant.getBenefitAvailabilityPolicy(), "the benefit availability policy is wrong"); + } + + @Test + public void testFindRestaurantByBogusMerchantNumber() { + assertThrows(EmptyResultDataAccessException.class, () -> { + repository.findByMerchantNumber("bogus"); + }); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/28-transactions/src/test/java/rewards/internal/restaurant/RestaurantTests.java b/lab/28-transactions/src/test/java/rewards/internal/restaurant/RestaurantTests.java new file mode 100644 index 0000000..93c2496 --- /dev/null +++ b/lab/28-transactions/src/test/java/rewards/internal/restaurant/RestaurantTests.java @@ -0,0 +1,71 @@ +package rewards.internal.restaurant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for exercising the behavior of the Restaurant aggregate entity. A restaurant calculates a benefit to award + * to an account for dining based on an availability policy and benefit percentage. + */ +public class RestaurantTests { + + private Restaurant restaurant; + + private Account account; + + private Dining dining; + + @BeforeEach + public void setUp() { + // configure the restaurant, the object being tested + restaurant = new Restaurant("1234567890", "AppleBee's"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(true)); + // configure supporting objects needed by the restaurant + account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle"); + dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + } + + @Test + public void testCalcuateBenefitFor() { + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert 8.00 eligible for reward + assertEquals(MonetaryAmount.valueOf("8.00"), benefit); + } + + @Test + public void testNoBenefitAvailable() { + // configure stub that always returns false + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(false)); + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert zero eligible for reward + assertEquals(MonetaryAmount.valueOf("0.00"), benefit); + } + + /** + * A simple "dummy" benefit availability policy containing a single flag used to determine if benefit is available. + * Only useful for testing--a real availability policy might consider many factors such as the day of week of the + * dining, or the account's reward history for the current month. + */ + private static class StubBenefitAvailibilityPolicy implements BenefitAvailabilityPolicy { + + private final boolean isBenefitAvailable; + + public StubBenefitAvailibilityPolicy(boolean isBenefitAvailable) { + this.isBenefitAvailable = isBenefitAvailable; + } + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return isBenefitAvailable; + } + } +} \ No newline at end of file diff --git a/lab/28-transactions/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java b/lab/28-transactions/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java new file mode 100644 index 0000000..79ee4f0 --- /dev/null +++ b/lab/28-transactions/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java @@ -0,0 +1,91 @@ +package rewards.internal.reward; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.math.BigDecimal; +import java.sql.SQLException; +import java.util.Map; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.Account; +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Tests the JDBC reward repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRewardRepositoryTests { + + private JdbcRewardRepository repository; + + private DataSource dataSource; + + private JdbcTemplate jdbcTemplate; + + @BeforeEach + public void setUp() { + repository = new JdbcRewardRepository(); + dataSource = createTestDataSource(); + repository.setDataSource(dataSource); + jdbcTemplate = new JdbcTemplate(dataSource); + } + + @Test + public void testCreateReward() throws SQLException { + Dining dining = Dining.createDining("100.00", "1234123412341234", "0123456789"); + + Account account = new Account("1", "Keith and Keri Donald"); + account.setEntityId(0L); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("8.00")); + RewardConfirmation confirmation = repository.confirmReward(contribution, dining); + assertNotNull(confirmation, "confirmation should not be null"); + assertNotNull(confirmation.getConfirmationNumber(), "confirmation number should not be null"); + assertEquals(contribution, confirmation.getAccountContribution(), "wrong contribution object"); + verifyRewardInserted(confirmation, dining); + } + + private void verifyRewardInserted(RewardConfirmation confirmation, Dining dining) { + assertEquals(1, getRewardCount()); + String sql = "select * from T_REWARD where CONFIRMATION_NUMBER = ?"; + Map values = jdbcTemplate.queryForMap(sql, confirmation.getConfirmationNumber()); + verifyInsertedValues(confirmation, dining, values); + } + + private void verifyInsertedValues(RewardConfirmation confirmation, Dining dining, Map values) { + assertEquals(confirmation.getAccountContribution().getAmount(), new MonetaryAmount((BigDecimal) values + .get("REWARD_AMOUNT"))); + assertEquals(SimpleDate.today().asDate(), values.get("REWARD_DATE")); + assertEquals(confirmation.getAccountContribution().getAccountNumber(), values.get("ACCOUNT_NUMBER")); + assertEquals(dining.getAmount(), new MonetaryAmount((BigDecimal) values.get("DINING_AMOUNT"))); + assertEquals(dining.getMerchantNumber(), values.get("DINING_MERCHANT_NUMBER")); + assertEquals(SimpleDate.today().asDate(), values.get("DINING_DATE")); + } + + private int getRewardCount() { + String sql = "select count(*) from T_REWARD"; + return jdbcTemplate.queryForObject(sql,Integer.class); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/30-jdbc-boot-solution/.gitignore b/lab/30-jdbc-boot-solution/.gitignore new file mode 100644 index 0000000..82eca33 --- /dev/null +++ b/lab/30-jdbc-boot-solution/.gitignore @@ -0,0 +1,25 @@ +/target/ +!.mvn/wrapper/maven-wrapper.jar + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/build/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ \ No newline at end of file diff --git a/lab/30-jdbc-boot-solution/.mvn/wrapper/maven-wrapper.jar b/lab/30-jdbc-boot-solution/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..c1dd12f Binary files /dev/null and b/lab/30-jdbc-boot-solution/.mvn/wrapper/maven-wrapper.jar differ diff --git a/lab/30-jdbc-boot-solution/.mvn/wrapper/maven-wrapper.properties b/lab/30-jdbc-boot-solution/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..b74bf7f --- /dev/null +++ b/lab/30-jdbc-boot-solution/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar diff --git a/lab/30-jdbc-boot-solution/build.gradle b/lab/30-jdbc-boot-solution/build.gradle new file mode 100644 index 0000000..5e6a204 --- /dev/null +++ b/lab/30-jdbc-boot-solution/build.gradle @@ -0,0 +1,7 @@ +apply plugin: "org.springframework.boot" + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-jdbc' + runtimeOnly 'org.hsqldb:hsqldb' + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} \ No newline at end of file diff --git a/lab/30-jdbc-boot-solution/gradle/wrapper/gradle-wrapper.jar b/lab/30-jdbc-boot-solution/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/lab/30-jdbc-boot-solution/gradle/wrapper/gradle-wrapper.jar differ diff --git a/lab/30-jdbc-boot-solution/gradle/wrapper/gradle-wrapper.properties b/lab/30-jdbc-boot-solution/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ae04661 --- /dev/null +++ b/lab/30-jdbc-boot-solution/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/lab/30-jdbc-boot-solution/gradlew b/lab/30-jdbc-boot-solution/gradlew new file mode 100755 index 0000000..a69d9cb --- /dev/null +++ b/lab/30-jdbc-boot-solution/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/lab/30-jdbc-boot-solution/gradlew.bat b/lab/30-jdbc-boot-solution/gradlew.bat new file mode 100644 index 0000000..53a6b23 --- /dev/null +++ b/lab/30-jdbc-boot-solution/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lab/30-jdbc-boot-solution/mvnw b/lab/30-jdbc-boot-solution/mvnw new file mode 100755 index 0000000..8a8fb22 --- /dev/null +++ b/lab/30-jdbc-boot-solution/mvnw @@ -0,0 +1,316 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/lab/30-jdbc-boot-solution/mvnw.cmd b/lab/30-jdbc-boot-solution/mvnw.cmd new file mode 100644 index 0000000..1d8ab01 --- /dev/null +++ b/lab/30-jdbc-boot-solution/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/lab/30-jdbc-boot-solution/pom.xml b/lab/30-jdbc-boot-solution/pom.xml new file mode 100644 index 0000000..923a138 --- /dev/null +++ b/lab/30-jdbc-boot-solution/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + io.spring.training.boot + 30-jdbc-boot-solution + 0.0.1-SNAPSHOT + jar + + First Boot Project + + + org.springframework.boot + spring-boot-starter-parent + 3.3.1 + + + + + UTF-8 + UTF-8 + + 17 + + + 3.1.1 + + + + + org.springframework.boot + spring-boot-starter-jdbc + + + + org.hsqldb + hsqldb + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/lab/30-jdbc-boot-solution/src/main/java/rewards/JdbcBootApplication.java b/lab/30-jdbc-boot-solution/src/main/java/rewards/JdbcBootApplication.java new file mode 100644 index 0000000..b00b153 --- /dev/null +++ b/lab/30-jdbc-boot-solution/src/main/java/rewards/JdbcBootApplication.java @@ -0,0 +1,25 @@ +package rewards; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.core.JdbcTemplate; + +@SpringBootApplication +public class JdbcBootApplication { + + public static void main(String[] args) { + SpringApplication.run(JdbcBootApplication.class, args); + } + + @Bean + CommandLineRunner commandLineRunner(JdbcTemplate jdbcTemplate){ + + String QUERY = "SELECT count(*) FROM T_ACCOUNT"; + + return args -> System.out.println("Hello, there are " + + jdbcTemplate.queryForObject(QUERY, Long.class) + + " accounts"); + } +} \ No newline at end of file diff --git a/lab/30-jdbc-boot-solution/src/main/resources/application.properties b/lab/30-jdbc-boot-solution/src/main/resources/application.properties new file mode 100644 index 0000000..488f181 --- /dev/null +++ b/lab/30-jdbc-boot-solution/src/main/resources/application.properties @@ -0,0 +1 @@ +logging.level.root=ERROR diff --git a/lab/30-jdbc-boot-solution/src/main/resources/banner.txt b/lab/30-jdbc-boot-solution/src/main/resources/banner.txt new file mode 100644 index 0000000..dcf6a06 --- /dev/null +++ b/lab/30-jdbc-boot-solution/src/main/resources/banner.txt @@ -0,0 +1,6 @@ + _________ .__ __________ __ + / _____/____________|__| ____ ____ \______ \ ____ _____/ |_ + \_____ \\____ \_ __ \ |/ \ / ___\ | | _// _ \ / _ \ __\ + / \ |_> > | \/ | | \/ /_/ > | | ( <_> | <_> ) | +/_______ / __/|__| |__|___| /\___ / |______ /\____/ \____/|__| + \/|__| \//_____/ \/ \ No newline at end of file diff --git a/lab/30-jdbc-boot-solution/src/main/resources/data.sql b/lab/30-jdbc-boot-solution/src/main/resources/data.sql new file mode 100644 index 0000000..28a87cc --- /dev/null +++ b/lab/30-jdbc-boot-solution/src/main/resources/data.sql @@ -0,0 +1,78 @@ + +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (17, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) + values ('1234567890', 'AppleBees', .08, 'A'); diff --git a/lab/30-jdbc-boot-solution/src/main/resources/schema.sql b/lab/30-jdbc-boot-solution/src/main/resources/schema.sql new file mode 100644 index 0000000..b0324fa --- /dev/null +++ b/lab/30-jdbc-boot-solution/src/main/resources/schema.sql @@ -0,0 +1,20 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, unique(NUMBER)); +create table T_ACCOUNT_CREDIT_CARD (ID integer identity primary key, ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(3,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_CREDIT_CARD add constraint FK_ACCOUNT_CREDIT_CARD foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; \ No newline at end of file diff --git a/lab/30-jdbc-boot-solution/src/test/java/rewards/JdbcBootApplicationTests.java b/lab/30-jdbc-boot-solution/src/test/java/rewards/JdbcBootApplicationTests.java new file mode 100644 index 0000000..f0a6778 --- /dev/null +++ b/lab/30-jdbc-boot-solution/src/test/java/rewards/JdbcBootApplicationTests.java @@ -0,0 +1,25 @@ +package rewards; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest +public class JdbcBootApplicationTests { + public static final String QUERY = "SELECT count(*) FROM T_ACCOUNT"; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + public void testNumberOfAccount() { + + long count = jdbcTemplate.queryForObject(QUERY, Long.class); + + assertThat(count).isEqualTo(21L); + } + +} diff --git a/lab/32-jdbc-autoconfig-solution/build.gradle b/lab/32-jdbc-autoconfig-solution/build.gradle new file mode 100644 index 0000000..dec5833 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/build.gradle @@ -0,0 +1,12 @@ +apply plugin: "org.springframework.boot" + +dependencies { + implementation project(':00-rewards-common') + implementation 'org.springframework.boot:spring-boot-starter-jdbc' +} + +// Create original jar file with "gradlew assemble" +jar { + enabled = true + archiveClassifier = 'original' +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/pom.xml b/lab/32-jdbc-autoconfig-solution/pom.xml new file mode 100644 index 0000000..98310f4 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + 32-jdbc-autoconfig-solution + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-jdbc + + + + org.hsqldb + hsqldb + + + + io.spring.training.core-spring + 00-rewards-common + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/config/RewardsConfig.java b/lab/32-jdbc-autoconfig-solution/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..f4b9a93 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/config/RewardsConfig.java @@ -0,0 +1,68 @@ +package config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import rewards.RewardNetwork; +import rewards.internal.RewardNetworkImpl; +import rewards.internal.account.AccountRepository; +import rewards.internal.account.JdbcAccountRepository; +import rewards.internal.restaurant.JdbcRestaurantRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.JdbcRewardRepository; +import rewards.internal.reward.RewardRepository; + +import javax.sql.DataSource; + +@Configuration +//@EnableAutoConfiguration(exclude = DataSourceAutoConfiguration.class) +public class RewardsConfig { + private final Logger logger + = LoggerFactory.getLogger(RewardsConfig.class); + +// @Autowired private DataSource dataSource; +// +// private DataSource dataSource() { +// return this.dataSource; +// } + + @Bean + public DataSource dataSource() { + logger.debug("Creating the datasource bean explicitly"); + + return + (new EmbeddedDatabaseBuilder()) + .addScript("classpath:/schema.sql") + .addScript("classpath:/data.sql") + .build(); + } + + @Bean + public RewardNetwork rewardNetwork(){ + return new RewardNetworkImpl( + accountRepository(), + restaurantRepository(), + rewardRepository()); + } + + @Bean + public AccountRepository accountRepository(){ + JdbcAccountRepository repository = new JdbcAccountRepository(dataSource()); + return repository; + } + + @Bean + public RestaurantRepository restaurantRepository(){ + JdbcRestaurantRepository repository = new JdbcRestaurantRepository(dataSource()); + return repository; + } + + @Bean + public RewardRepository rewardRepository(){ + JdbcRewardRepository repository = new JdbcRewardRepository(dataSource()); + return repository; + } + +} diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/AccountContribution.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..beb796d --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +import java.util.Set; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/Dining.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/RewardConfirmation.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/RewardNetwork.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/RewardsApplication.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/RewardsApplication.java new file mode 100644 index 0000000..22788f5 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/RewardsApplication.java @@ -0,0 +1,40 @@ +package rewards; + +import config.RewardsConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +@SpringBootApplication +@EnableConfigurationProperties(RewardsRecipientProperties.class) +//@ConfigurationPropertiesScan +@Import(RewardsConfig.class) +public class RewardsApplication { + private final Logger logger + = LoggerFactory.getLogger(RewardsApplication.class); + + public static void main(String[] args) { + SpringApplication.run(RewardsApplication.class,args); + } + + @Bean + CommandLineRunner commandLineRunner(JdbcTemplate jdbcTemplate){ + + String QUERY = "SELECT count(*) FROM T_ACCOUNT"; + + Long numberOfAccounts = jdbcTemplate.queryForObject(QUERY, Long.class); + + return args -> logger.info("Hello, there are {} accounts", numberOfAccounts); + } + + @Bean + CommandLineRunner commandLineRunner2(RewardsRecipientProperties rewardsRecipientProperties) { + return args -> logger.info("Recipient: " + rewardsRecipientProperties.getName()); + } +} diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/RewardsRecipientProperties.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/RewardsRecipientProperties.java new file mode 100644 index 0000000..edadf0b --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/RewardsRecipientProperties.java @@ -0,0 +1,44 @@ +package rewards; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "rewards.recipient") +public class RewardsRecipientProperties { + + private String name; + private int age; + private String gender; + private String hobby; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } + + public String getGender() { + return gender; + } + + public void setGender(String gender) { + this.gender = gender; + } + + public String getHobby() { + return hobby; + } + + public void setHobby(String hobby) { + this.hobby = hobby; + } +} diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..e8c8e07 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,51 @@ +package rewards.internal; + +import common.money.MonetaryAmount; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/Account.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..c8f03de --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,158 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + try { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } catch (IllegalArgumentException e) { + // total would have been over 100% - return invalid + return false; + } + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. Callers should not attempt to hold on or modify the returned set. + * This method should only be used transitively; for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Returns a single account beneficiary. Callers should not attempt to hold on or modify the returned object. This + * method should only be used transitively; for example, called to facilitate reporting or testing. + * @param name the name of the beneficiary e.g "Annabelle" + * @return the beneficiary object + */ + public Beneficiary getBeneficiary(String name) { + for (Beneficiary b : beneficiaries) { + if (b.getName().equals(name)) { + return b; + } + } + throw new IllegalArgumentException("No such beneficiary with name '" + name + "'"); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/AccountRepository.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..16c6079 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/Beneficiary.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java new file mode 100644 index 0000000..33392fd --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/JdbcAccountRepository.java @@ -0,0 +1,90 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.ResultSetExtractor; + +import javax.sql.DataSource; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Loads accounts from a data source using the JDBC API. + */ +public class JdbcAccountRepository implements AccountRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcAccountRepository(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + /** + * Extracts an Account object from rows returned from a join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY. + */ + private final ResultSetExtractor accountExtractor = new AccountExtractor(); + + public Account findByCreditCard(String creditCardNumber) { + String sql = "select a.ID as ID, a.NUMBER as ACCOUNT_NUMBER, a.NAME as ACCOUNT_NAME, c.NUMBER as CREDIT_CARD_NUMBER, b.NAME as BENEFICIARY_NAME, b.ALLOCATION_PERCENTAGE as BENEFICIARY_ALLOCATION_PERCENTAGE, b.SAVINGS as BENEFICIARY_SAVINGS from T_ACCOUNT a, T_ACCOUNT_BENEFICIARY b, T_ACCOUNT_CREDIT_CARD c where ID = b.ACCOUNT_ID and ID = c.ACCOUNT_ID and c.NUMBER = ?"; + return jdbcTemplate.query(sql, accountExtractor, creditCardNumber); + } + + public void updateBeneficiaries(Account account) { + String sql = "update T_ACCOUNT_BENEFICIARY SET SAVINGS = ? where ACCOUNT_ID = ? and NAME = ?"; + for (Beneficiary b : account.getBeneficiaries()) { + jdbcTemplate.update(sql, b.getSavings().asBigDecimal(), account.getEntityId(), b.getName()); + } + } + + /** + * Map the rows returned from the join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY to a fully-reconstituted Account + * aggregate. + * + * @param rs the set of rows returned from the query + * @return the mapped Account aggregate + * @throws SQLException an exception occurred extracting data from the result set + */ + private Account mapAccount(ResultSet rs) throws SQLException { + Account account = null; + while (rs.next()) { + if (account == null) { + String number = rs.getString("ACCOUNT_NUMBER"); + String name = rs.getString("ACCOUNT_NAME"); + account = new Account(number, name); + // set internal entity identifier (primary key) + account.setEntityId(rs.getLong("ID")); + } + account.restoreBeneficiary(mapBeneficiary(rs)); + } + if (account == null) { + // no rows returned - throw an empty result exception + throw new EmptyResultDataAccessException(1); + } + return account; + } + + /** + * Maps the beneficiary columns in a single row to an AllocatedBeneficiary object. + * + * @param rs the result set with its cursor positioned at the current row + * @return an allocated beneficiary + * @throws SQLException an exception occurred extracting data from the result set + */ + private Beneficiary mapBeneficiary(ResultSet rs) throws SQLException { + String name = rs.getString("BENEFICIARY_NAME"); + MonetaryAmount savings = MonetaryAmount.valueOf(rs.getString("BENEFICIARY_SAVINGS")); + Percentage allocationPercentage = Percentage.valueOf(rs.getString("BENEFICIARY_ALLOCATION_PERCENTAGE")); + return new Beneficiary(name, allocationPercentage, savings); + } + + private class AccountExtractor implements ResultSetExtractor { + + public Account extractData(ResultSet rs) throws SQLException, DataAccessException { + return mapAccount(rs); + } + + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/package.html b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/package.html b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java new file mode 100644 index 0000000..b7d6d74 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java @@ -0,0 +1,20 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * Determines if benefit is available for an account for dining. + * + * A value object. A strategy. Scoped by the Resturant aggregate. + */ +public interface BenefitAvailabilityPolicy { + + /** + * Calculates if an account is eligible to receive benefits for a dining. + * @param account the account of the member who dined + * @param dining the dining event + * @return benefit availability status + */ + boolean isBenefitAvailableFor(Account account, Dining dining); +} diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java new file mode 100644 index 0000000..62f11a6 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java @@ -0,0 +1,114 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import rewards.Dining; +import rewards.internal.account.Account; + +import javax.sql.DataSource; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Loads restaurants from a data source using the JDBC API. + */ +public class JdbcRestaurantRepository implements RestaurantRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcRestaurantRepository(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + */ + private final RowMapper rowMapper = new RestaurantRowMapper(); + + public Restaurant findByMerchantNumber(String merchantNumber) { + String sql = "select MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY from T_RESTAURANT where MERCHANT_NUMBER = ?"; + return jdbcTemplate.queryForObject(sql, rowMapper, merchantNumber); + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + * + * @param rs the result set with its cursor positioned at the current row + */ + private Restaurant mapRestaurant(ResultSet rs) throws SQLException { + // get the row column data + String name = rs.getString("NAME"); + String number = rs.getString("MERCHANT_NUMBER"); + Percentage benefitPercentage = Percentage.valueOf(rs.getString("BENEFIT_PERCENTAGE")); + // map to the object + Restaurant restaurant = new Restaurant(number, name); + restaurant.setBenefitPercentage(benefitPercentage); + restaurant.setBenefitAvailabilityPolicy(mapBenefitAvailabilityPolicy(rs)); + return restaurant; + } + + /** + * Helper method that maps benefit availability policy data in the ResultSet to a fully-configured + * {@link BenefitAvailabilityPolicy} object. The key column is 'BENEFIT_AVAILABILITY_POLICY', which is a + * discriminator column containing a string code that identifies the type of policy. Currently supported types are: + * 'A' for 'always available' and 'N' for 'never available'. + * + *

+ * More types could be added easily by enhancing this method. For example, 'W' for 'Weekdays only' or 'M' for 'Max + * Rewards per Month'. Some of these types might require additional database column values to be configured, for + * example a 'MAX_REWARDS_PER_MONTH' data column. + * + * @param rs the result set used to map the policy object from database column values + * @return the matching benefit availability policy + * @throws IllegalArgumentException if the mapping could not be performed + */ + private BenefitAvailabilityPolicy mapBenefitAvailabilityPolicy(ResultSet rs) throws SQLException { + String policyCode = rs.getString("BENEFIT_AVAILABILITY_POLICY"); + if ("A".equals(policyCode)) { + return AlwaysAvailable.INSTANCE; + } else if ("N".equals(policyCode)) { + return NeverAvailable.INSTANCE; + } else { + throw new IllegalArgumentException("Not a supported policy code " + policyCode); + } + } + + /** + * Returns true indicating benefit is always available. + */ + static class AlwaysAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new AlwaysAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + + public String toString() { + return "alwaysAvailable"; + } + } + + /** + * Returns false indicating benefit is never available. + */ + static class NeverAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new NeverAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return false; + } + + public String toString() { + return "neverAvailable"; + } + } + + private class RestaurantRowMapper implements RowMapper { + + public Restaurant mapRow(ResultSet rs, int rowNum) throws SQLException { + return mapRestaurant(rs); + } + + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..50399f6 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,101 @@ +package rewards.internal.restaurant; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on an availability policy and a + * benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + private BenefitAvailabilityPolicy benefitAvailabilityPolicy; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Sets the policy that determines if a dining by an account at this restaurant is eligible for benefit. + * @param benefitAvailabilityPolicy the benefit availability policy + */ + public void setBenefitAvailabilityPolicy(BenefitAvailabilityPolicy benefitAvailabilityPolicy) { + this.benefitAvailabilityPolicy = benefitAvailabilityPolicy; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Returns this restaurant's benefit availability policy. + */ + public BenefitAvailabilityPolicy getBenefitAvailabilityPolicy() { + return benefitAvailabilityPolicy; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + if (benefitAvailabilityPolicy.isBenefitAvailableFor(account, dining)) { + return dining.getAmount().multiplyBy(benefitPercentage); + } else { + return MonetaryAmount.zero(); + } + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage + + ", benefitAvailabilityPolicy = " + benefitAvailabilityPolicy; + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/package.html b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..04f6502 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,36 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import org.springframework.jdbc.core.JdbcTemplate; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import javax.sql.DataSource; + +/** + * JDBC implementation of a reward repository that records the result of a reward transaction by inserting a reward + * confirmation record. + */ +public class JdbcRewardRepository implements RewardRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcRewardRepository(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + String confirmationNumber = nextConfirmationNumber(); + jdbcTemplate.update(sql, confirmationNumber, contribution.getAmount().asBigDecimal(), + SimpleDate.today().asDate(), contribution.getAccountNumber(), dining.getMerchantNumber(), + dining.getDate().asDate(), dining.getAmount().asBigDecimal()); + return new RewardConfirmation(confirmationNumber, contribution); + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + return jdbcTemplate.queryForObject(sql, String.class); + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/reward/package.html b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/package.html b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/32-jdbc-autoconfig-solution/src/main/resources/application.properties b/lab/32-jdbc-autoconfig-solution/src/main/resources/application.properties new file mode 100644 index 0000000..aa13221 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/resources/application.properties @@ -0,0 +1,10 @@ +logging.level.root=ERROR +logging.level.rewards=INFO +logging.level.config=DEBUG + +rewards.recipient.name=John Doe +rewards.recipient.age=10 +rewards.recipient.gender=Male +rewards.recipient.hobby=Tennis + +spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/main/resources/data.sql b/lab/32-jdbc-autoconfig-solution/src/main/resources/data.sql new file mode 100644 index 0000000..28a87cc --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/resources/data.sql @@ -0,0 +1,78 @@ + +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (17, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) + values ('1234567890', 'AppleBees', .08, 'A'); diff --git a/lab/32-jdbc-autoconfig-solution/src/main/resources/schema.sql b/lab/32-jdbc-autoconfig-solution/src/main/resources/schema.sql new file mode 100644 index 0000000..b0324fa --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/main/resources/schema.sql @@ -0,0 +1,20 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, unique(NUMBER)); +create table T_ACCOUNT_CREDIT_CARD (ID integer identity primary key, ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(3,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_CREDIT_CARD add constraint FK_ACCOUNT_CREDIT_CARD foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/RewardNetworkTests.java b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..ed7fa9d --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,53 @@ +package rewards; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A system test that verifies the components of the RewardNetwork application work together to reward for dining + * successfully. Uses Spring to bootstrap the application for use in a test environment. + */ +@SpringBootTest +public class RewardNetworkTests { + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/SystemTestConfig.java b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/SystemTestConfig.java new file mode 100644 index 0000000..1b15e84 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/SystemTestConfig.java @@ -0,0 +1,32 @@ +package rewards; + +import config.RewardsConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * Sets up an embedded in-memory HSQL database, primarily for testing. + */ +@Configuration +@Import(RewardsConfig.class) +public class SystemTestConfig { + private final Logger logger = LoggerFactory.getLogger(SystemTestConfig.class); + + /** + * Creates an in-memory "rewards" database populated + * with test data for fast testing + */ +// @Bean +// public DataSource dataSource() { +// logger.debug("Creating the datasource bean explicitly"); +// +// return +// (new EmbeddedDatabaseBuilder()) +// .addScript("classpath:rewards/testdb/schema.sql") +// .addScript("classpath:rewards/testdb/data.sql") +// .build(); +// } + +} diff --git a/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..77a6039 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,70 @@ +package rewards.internal; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/StubAccountRepository.java b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..41c4df5 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,41 @@ +package rewards.internal; + +import common.money.Percentage; +import org.springframework.dao.EmptyResultDataAccessException; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import java.util.HashMap; +import java.util.Map; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new EmptyResultDataAccessException(1); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..13993a9 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,51 @@ +package rewards.internal; + +import common.money.Percentage; +import org.springframework.dao.EmptyResultDataAccessException; +import rewards.Dining; +import rewards.internal.account.Account; +import rewards.internal.restaurant.BenefitAvailabilityPolicy; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import java.util.HashMap; +import java.util.Map; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new AlwaysReturnsTrue()); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } + + /** + * A simple "dummy" benefit availability policy that always returns true. Only useful for testing--a real + * availability policy might consider many factors such as the day of week of the dining, or the account's reward + * history for the current month. + */ + private static class AlwaysReturnsTrue implements BenefitAvailabilityPolicy { + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/StubRewardRepository.java b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..d149acc --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +import java.util.Random; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/account/AccountTests.java b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/account/AccountTests.java new file mode 100644 index 0000000..d2a53ae --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/account/AccountTests.java @@ -0,0 +1,53 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.Test; +import rewards.AccountContribution; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the Account class that verify Account behavior works in isolation. + */ +public class AccountTests { + + private final Account account = new Account("1", "Keith and Keri Donald"); + + @Test + public void accountIsValid() { + // setup account with a valid set of beneficiaries to prepare for testing + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + assertTrue(account.isValid()); + } + + @Test + public void accountIsInvalidWithNoBeneficiaries() { + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreOver100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreUnder100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("25%")); + assertFalse(account.isValid()); + } + + @Test + public void makeContribution() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("100.00")); + assertEquals(contribution.getAmount(), MonetaryAmount.valueOf("100.00")); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java new file mode 100644 index 0000000..b1615ad --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java @@ -0,0 +1,95 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC account repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcAccountRepositoryTests { + + private JdbcAccountRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + repository = new JdbcAccountRepository(dataSource); + } + + @Test + public void testFindAccountByCreditCard() { + Account account = repository.findByCreditCard("1234123412341234"); + // assert the returned account contains what you expect given the state of the database + assertNotNull(account, "account should never be null"); + assertEquals(Long.valueOf(0), account.getEntityId(), "wrong entity id"); + assertEquals("123456789", account.getNumber(), "wrong account number"); + assertEquals("Keith and Keri Donald", account.getName(), "wrong name"); + assertEquals(2, account.getBeneficiaries().size(), "wrong beneficiary collection size"); + + Beneficiary b1 = account.getBeneficiary("Annabelle"); + assertNotNull(b1, "Annabelle should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b1.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b1.getAllocationPercentage(), "wrong allocation percentage"); + + Beneficiary b2 = account.getBeneficiary("Corgan"); + assertNotNull(b2, "Corgan should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b2.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b2.getAllocationPercentage(), "wrong allocation percentage"); + } + + @Test + public void testFindAccountByCreditCardNoAccount() { + assertThrows(EmptyResultDataAccessException.class, () -> { + repository.findByCreditCard("bogus"); + }); + } + + @Test + public void testUpdateBeneficiaries() throws SQLException { + Account account = repository.findByCreditCard("1234123412341234"); + account.makeContribution(MonetaryAmount.valueOf("8.00")); + repository.updateBeneficiaries(account); + verifyBeneficiaryTableUpdated(); + } + + private void verifyBeneficiaryTableUpdated() throws SQLException { + String sql = "select SAVINGS from T_ACCOUNT_BENEFICIARY where NAME = ? and ACCOUNT_ID = ?"; + PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql); + + // assert Annabelle has $4.00 savings now + stmt.setString(1, "Annabelle"); + stmt.setLong(2, 0L); + ResultSet rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + + // assert Corgan has $4.00 savings now + stmt.setString(1, "Corgan"); + stmt.setLong(2, 0L); + rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java new file mode 100644 index 0000000..06ca758 --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java @@ -0,0 +1,51 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC restaurant repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRestaurantRepositoryTests { + + private JdbcRestaurantRepository repository; + + @BeforeEach + public void setUp() { + repository = new JdbcRestaurantRepository(createTestDataSource()); + } + + @Test + public void testFindRestaurantByMerchantNumber() { + Restaurant restaurant = repository.findByMerchantNumber("1234567890"); + assertNotNull(restaurant, "the restaurant should never be null"); + assertEquals("1234567890", restaurant.getNumber(), "the merchant number is wrong"); + assertEquals("AppleBees", restaurant.getName(), "the name is wrong"); + assertEquals(Percentage.valueOf("8%"), restaurant.getBenefitPercentage(), "the benefitPercentage is wrong"); + assertEquals(JdbcRestaurantRepository.AlwaysAvailable.INSTANCE, + restaurant.getBenefitAvailabilityPolicy(), "the benefit availability policy is wrong"); + } + + @Test + public void testFindRestaurantByBogusMerchantNumber() { + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("bogus"); + }); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java new file mode 100644 index 0000000..450e81c --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java @@ -0,0 +1,69 @@ +package rewards.internal.restaurant; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import rewards.Dining; +import rewards.internal.account.Account; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Unit tests for exercising the behavior of the Restaurant aggregate entity. A restaurant calculates a benefit to award + * to an account for dining based on an availability policy and benefit percentage. + */ +public class RestaurantTests { + + private Restaurant restaurant; + + private Account account; + + private Dining dining; + + @BeforeEach + public void setUp() { + // configure the restaurant, the object being tested + restaurant = new Restaurant("1234567890", "AppleBee's"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(true)); + // configure supporting objects needed by the restaurant + account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle"); + dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + } + + @Test + public void testCalcuateBenefitFor() { + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert 8.00 eligible for reward + assertEquals(MonetaryAmount.valueOf("8.00"), benefit); + } + + @Test + public void testNoBenefitAvailable() { + // configure stub that always returns false + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(false)); + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert zero eligible for reward + assertEquals(MonetaryAmount.valueOf("0.00"), benefit); + } + + /** + * A simple "dummy" benefit availability policy containing a single flag used to determine if benefit is available. + * Only useful for testing--a real availability policy might consider many factors such as the day of week of the + * dining, or the account's reward history for the current month. + */ + private static class StubBenefitAvailibilityPolicy implements BenefitAvailabilityPolicy { + + private final boolean isBenefitAvailable; + + public StubBenefitAvailibilityPolicy(boolean isBenefitAvailable) { + this.isBenefitAvailable = isBenefitAvailable; + } + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return isBenefitAvailable; + } + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java new file mode 100644 index 0000000..be8f7cc --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java @@ -0,0 +1,88 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.Account; + +import javax.sql.DataSource; +import java.math.BigDecimal; +import java.sql.SQLException; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests the JDBC reward repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRewardRepositoryTests { + + private JdbcRewardRepository repository; + + private DataSource dataSource; + + private JdbcTemplate jdbcTemplate; + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + repository = new JdbcRewardRepository(dataSource); + jdbcTemplate = new JdbcTemplate(dataSource); + } + + @Test + public void testCreateReward() throws SQLException { + Dining dining = Dining.createDining("100.00", "1234123412341234", "0123456789"); + + Account account = new Account("1", "Keith and Keri Donald"); + account.setEntityId(0L); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("8.00")); + RewardConfirmation confirmation = repository.confirmReward(contribution, dining); + assertNotNull(confirmation, "confirmation should not be null"); + assertNotNull(confirmation.getConfirmationNumber(), "confirmation number should not be null"); + assertEquals(contribution, confirmation.getAccountContribution(), "wrong contribution object"); + verifyRewardInserted(confirmation, dining); + } + + private void verifyRewardInserted(RewardConfirmation confirmation, Dining dining) { + assertEquals(1, getRewardCount()); + String sql = "select * from T_REWARD where CONFIRMATION_NUMBER = ?"; + Map values = jdbcTemplate.queryForMap(sql, confirmation.getConfirmationNumber()); + verifyInsertedValues(confirmation, dining, values); + } + + private void verifyInsertedValues(RewardConfirmation confirmation, Dining dining, Map values) { + assertEquals(confirmation.getAccountContribution().getAmount(), new MonetaryAmount((BigDecimal) values + .get("REWARD_AMOUNT"))); + assertEquals(SimpleDate.today().asDate(), values.get("REWARD_DATE")); + assertEquals(confirmation.getAccountContribution().getAccountNumber(), values.get("ACCOUNT_NUMBER")); + assertEquals(dining.getAmount(), new MonetaryAmount((BigDecimal) values.get("DINING_AMOUNT"))); + assertEquals(dining.getMerchantNumber(), values.get("DINING_MERCHANT_NUMBER")); + assertEquals(SimpleDate.today().asDate(), values.get("DINING_DATE")); + } + + private int getRewardCount() { + String sql = "select count(*) from T_REWARD"; + return jdbcTemplate.queryForObject(sql, Integer.class); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/32-jdbc-autoconfig-solution/src/test/resources/rewards/testdb/data.sql b/lab/32-jdbc-autoconfig-solution/src/test/resources/rewards/testdb/data.sql new file mode 100644 index 0000000..28a87cc --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/test/resources/rewards/testdb/data.sql @@ -0,0 +1,78 @@ + +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (17, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) + values ('1234567890', 'AppleBees', .08, 'A'); diff --git a/lab/32-jdbc-autoconfig-solution/src/test/resources/rewards/testdb/schema.sql b/lab/32-jdbc-autoconfig-solution/src/test/resources/rewards/testdb/schema.sql new file mode 100644 index 0000000..b0324fa --- /dev/null +++ b/lab/32-jdbc-autoconfig-solution/src/test/resources/rewards/testdb/schema.sql @@ -0,0 +1,20 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, unique(NUMBER)); +create table T_ACCOUNT_CREDIT_CARD (ID integer identity primary key, ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(3,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_CREDIT_CARD add constraint FK_ACCOUNT_CREDIT_CARD foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/build.gradle b/lab/32-jdbc-autoconfig/build.gradle new file mode 100644 index 0000000..045a70b --- /dev/null +++ b/lab/32-jdbc-autoconfig/build.gradle @@ -0,0 +1,17 @@ +// TO-DO-01 : Add the Spring Boot Plugin + + +dependencies { + implementation project(':00-rewards-common') + + // TO-DO-02 : Refactor from discrete Spring Framework + // dependency to JDBC Boot Starter + implementation 'org.springframework:spring-jdbc' + +} + +// Create original jar file with "gradlew assemble" +jar { + enabled = true + archiveClassifier = 'original' +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/pom.xml b/lab/32-jdbc-autoconfig/pom.xml new file mode 100644 index 0000000..ed3f260 --- /dev/null +++ b/lab/32-jdbc-autoconfig/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + 32-jdbc-autoconfig + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + + + org.springframework.boot + spring-boot-starter + + + + + + + org.springframework + spring-jdbc + + + + + org.springframework + spring-test + test + + + + + org.hsqldb + hsqldb + + + + + io.spring.training.core-spring + 00-rewards-common + + + + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + + diff --git a/lab/32-jdbc-autoconfig/src/main/java/config/RewardsConfig.java b/lab/32-jdbc-autoconfig/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..11792d6 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/config/RewardsConfig.java @@ -0,0 +1,77 @@ +package config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import rewards.RewardNetwork; +import rewards.internal.RewardNetworkImpl; +import rewards.internal.account.AccountRepository; +import rewards.internal.account.JdbcAccountRepository; +import rewards.internal.restaurant.JdbcRestaurantRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.JdbcRewardRepository; +import rewards.internal.reward.RewardRepository; + +import javax.sql.DataSource; + +@Configuration +public class RewardsConfig { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + final DataSource dataSource; + + public RewardsConfig(DataSource dataSource) { + this.dataSource = dataSource; + } + + // TODO-10 (Optional) : Switch back to explicit `DataSource` configuration + // (Instead of using auto-configured DataSource, we are going to configure + // our own DataSource bean. Normally we want to configure infra-structure + // bean such as DataSource bean in a separate configuration class but + // here for the sake of simplicity, we configure it along with application + // beans.) + // - Uncomment @Bean method below + // - Remove the code above that performs DataSource injection + // - Fix compile errors in this code + /* + @Bean + public DataSource dataSource() { + logger.debug("Creating the datasource bean explicitly"); + + return + (new EmbeddedDatabaseBuilder()) + .addScript("classpath:schema.sql") + .addScript("classpath:data.sql") + .build(); + } + */ + + @Bean + public RewardNetwork rewardNetwork() { + return new RewardNetworkImpl( + accountRepository(), + restaurantRepository(), + rewardRepository()); + } + + @Bean + public AccountRepository accountRepository() { + JdbcAccountRepository repository = new JdbcAccountRepository(dataSource); + return repository; + } + + @Bean + public RestaurantRepository restaurantRepository() { + JdbcRestaurantRepository repository = new JdbcRestaurantRepository(dataSource); + return repository; + } + + @Bean + public RewardRepository rewardRepository() { + JdbcRewardRepository repository = new JdbcRewardRepository(dataSource); + return repository; + } + +} diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/AccountContribution.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..beb796d --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +import java.util.Set; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/Dining.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/RewardConfirmation.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/RewardNetwork.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/RewardsApplication.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/RewardsApplication.java new file mode 100644 index 0000000..6fdbde6 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/RewardsApplication.java @@ -0,0 +1,71 @@ +package rewards; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.SpringApplication; + +// TODO-00 : In this lab, you are going to exercise the following: +// - Understanding how auto-configuration is triggered in Spring Boot application +// - Using auto-configuring DataSource in test and application code +// - Understanding how @SpringBootTest is used to create application context in test +// - Implementing CommandLineRunner using auto-configured JdbcTemplate +// - Disabling a particular auto-configuration +// - Exercising the usage of @ConfigurationProperties + +// TODO-01 : Open pom.xml or build.gradle, look for TO-DO-01 + +// TODO-02 : In pom.xml or build.gradle, look for TO-DO-02 + +// TODO-03 : Turn this 'RewardsApplication' into a Spring Boot application +// - Add an appropriate annotation to this class + +// -------------------------------------------- + +// TODO-11 (Optional) : Disable 'DataSource' auto-configuration +// - Note that you are using your own 'DataSource' bean now +// instead of auto-configured one +// - Use 'exclude' attribute of '@SpringBootApplication' +// excluding 'DataSourceAutoConfiguration' class +// - Run this application and observe a failure +// - Import 'RewardsConfig' class +// - Run this application again and observe a successful execution + +// TODO-12 (Optional) : Look in application.properties for the next step. + +// TODO-13 (Optional) : Follow the instruction in the lab document. +// The section titled "Build and Run using Command Line tools". + +public class RewardsApplication { + static final String SQL = "SELECT count(*) FROM T_ACCOUNT"; + + final Logger logger + = LoggerFactory.getLogger(RewardsApplication.class); + + public static void main(String[] args) { + SpringApplication.run(RewardsApplication.class, args); + } + + // TODO-04 : Let Spring Boot execute database scripts + // - Move the SQL scripts (schema.sql and data.sql) + // from `src/test/resources/rewards/testdb` directory + // to `src/main/resources/` directory + + // TODO-05 : Implement a command line runner that will query count from + // T_ACCOUNT table and log the count to the console + // - Use the SQL query and logger provided above. + // - Use the JdbcTemplate bean that Spring Boot auto-configured for you + // - Run this application and verify "Hello, there are 21 accounts" log message + // gets displayed in the console + + // TODO-07 (Optional): Enable full debugging in order to observe how Spring Boot + // performs its auto-configuration logic + // - Follow TO-DO-07 in application.properties, then come back here. + // - Run the application + // - In the console output, find "CONDITIONS EVALUATION REPORT". + // It represents the auto-configuration logic used by Spring Boot. + // - Search for "JdbcTemplateAutoConfiguration matched:" and + // "DataSourceAutoConfiguration matched:". Note that each @Conditional* + // represents a single conditional statement in the "JdbcTemplateAutoConfiguration" + // and "DataSourceAutoConfiguration" classes. + +} diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/RewardsRecipientProperties.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/RewardsRecipientProperties.java new file mode 100644 index 0000000..ad54529 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/RewardsRecipientProperties.java @@ -0,0 +1,24 @@ +package rewards; + +// TODO-06 : Capture properties into a class using @ConfigurationProperties +// - Note that application.properties file already contains the following properties +// +// rewards.recipient.name=John Doe +// rewards.recipient.age=10 +// rewards.recipient.gender=Male +// rewards.recipient.hobby=Tennis +// +// - Annotate this class with @ConfigurationProperties +// with prefix attribute set to a proper value +// - Create fields (along with needed getters/setters) that reflect the +// properties above in the RewardsRecipientProperties class +// - Use one of the following 3 schemes to enable @ConfigurationProperties +// (1) Add @EnableConfigurationProperties(RewardsRecipientProperties.class) +// to the RewardsApplication class +// (2) Add @ConfigurationPropertiesScan to RewardsApplication class or +// (3) Annotate this class with @Component +// - Implement a new command line runner that displays the name of the rewards +// recipient when the application gets started +public class RewardsRecipientProperties { + +} diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..e8c8e07 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,51 @@ +package rewards.internal; + +import common.money.MonetaryAmount; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + accountRepository.updateBeneficiaries(account); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/Account.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..c8f03de --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,158 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +public class Account extends Entity { + + private String number; + + private String name; + + private Set beneficiaries = new HashSet<>(); + + @SuppressWarnings("unused") + private Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + try { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } catch (IllegalArgumentException e) { + // total would have been over 100% - return invalid + return false; + } + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. Callers should not attempt to hold on or modify the returned set. + * This method should only be used transitively; for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Returns a single account beneficiary. Callers should not attempt to hold on or modify the returned object. This + * method should only be used transitively; for example, called to facilitate reporting or testing. + * @param name the name of the beneficiary e.g "Annabelle" + * @return the beneficiary object + */ + public Beneficiary getBeneficiary(String name) { + for (Beneficiary b : beneficiaries) { + if (b.getName().equals(name)) { + return b; + } + } + throw new IllegalArgumentException("No such beneficiary with name '" + name + "'"); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/AccountRepository.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..16c6079 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,29 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and reconstitute Account entities from an external + * form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface AccountRepository { + + /** + * Load an account by its credit card. + * @param creditCardNumber the credit card number + * @return the account object + */ + Account findByCreditCard(String creditCardNumber); + + /** + * Updates the 'savings' of each account beneficiary. The new savings balance contains the amount distributed for a + * contribution made during a reward transaction. + *

+ * Note: use of an object-relational mapper (ORM) with support for transparent-persistence like Hibernate (or the + * new Java Persistence API (JPA)) would remove the need for this explicit update operation as the ORM would take + * care of applying relational updates to a modified Account entity automatically. + * @param account the account whose beneficiary savings have changed + */ + void updateBeneficiaries(Account account); + +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/Beneficiary.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..647499b --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,79 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +public class Beneficiary extends Entity { + + private String name; + + private Percentage allocationPercentage; + + private MonetaryAmount savings = MonetaryAmount.valueOf("0.00"); + + @SuppressWarnings("unused") + private Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/JdbcAccountRepository.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/JdbcAccountRepository.java new file mode 100644 index 0000000..33392fd --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/JdbcAccountRepository.java @@ -0,0 +1,90 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.ResultSetExtractor; + +import javax.sql.DataSource; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Loads accounts from a data source using the JDBC API. + */ +public class JdbcAccountRepository implements AccountRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcAccountRepository(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + /** + * Extracts an Account object from rows returned from a join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY. + */ + private final ResultSetExtractor accountExtractor = new AccountExtractor(); + + public Account findByCreditCard(String creditCardNumber) { + String sql = "select a.ID as ID, a.NUMBER as ACCOUNT_NUMBER, a.NAME as ACCOUNT_NAME, c.NUMBER as CREDIT_CARD_NUMBER, b.NAME as BENEFICIARY_NAME, b.ALLOCATION_PERCENTAGE as BENEFICIARY_ALLOCATION_PERCENTAGE, b.SAVINGS as BENEFICIARY_SAVINGS from T_ACCOUNT a, T_ACCOUNT_BENEFICIARY b, T_ACCOUNT_CREDIT_CARD c where ID = b.ACCOUNT_ID and ID = c.ACCOUNT_ID and c.NUMBER = ?"; + return jdbcTemplate.query(sql, accountExtractor, creditCardNumber); + } + + public void updateBeneficiaries(Account account) { + String sql = "update T_ACCOUNT_BENEFICIARY SET SAVINGS = ? where ACCOUNT_ID = ? and NAME = ?"; + for (Beneficiary b : account.getBeneficiaries()) { + jdbcTemplate.update(sql, b.getSavings().asBigDecimal(), account.getEntityId(), b.getName()); + } + } + + /** + * Map the rows returned from the join of T_ACCOUNT and T_ACCOUNT_BENEFICIARY to a fully-reconstituted Account + * aggregate. + * + * @param rs the set of rows returned from the query + * @return the mapped Account aggregate + * @throws SQLException an exception occurred extracting data from the result set + */ + private Account mapAccount(ResultSet rs) throws SQLException { + Account account = null; + while (rs.next()) { + if (account == null) { + String number = rs.getString("ACCOUNT_NUMBER"); + String name = rs.getString("ACCOUNT_NAME"); + account = new Account(number, name); + // set internal entity identifier (primary key) + account.setEntityId(rs.getLong("ID")); + } + account.restoreBeneficiary(mapBeneficiary(rs)); + } + if (account == null) { + // no rows returned - throw an empty result exception + throw new EmptyResultDataAccessException(1); + } + return account; + } + + /** + * Maps the beneficiary columns in a single row to an AllocatedBeneficiary object. + * + * @param rs the result set with its cursor positioned at the current row + * @return an allocated beneficiary + * @throws SQLException an exception occurred extracting data from the result set + */ + private Beneficiary mapBeneficiary(ResultSet rs) throws SQLException { + String name = rs.getString("BENEFICIARY_NAME"); + MonetaryAmount savings = MonetaryAmount.valueOf(rs.getString("BENEFICIARY_SAVINGS")); + Percentage allocationPercentage = Percentage.valueOf(rs.getString("BENEFICIARY_ALLOCATION_PERCENTAGE")); + return new Beneficiary(name, allocationPercentage, savings); + } + + private class AccountExtractor implements ResultSetExtractor { + + public Account extractData(ResultSet rs) throws SQLException, DataAccessException { + return mapAccount(rs); + } + + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/package.html b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/package.html b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java new file mode 100644 index 0000000..b7d6d74 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java @@ -0,0 +1,20 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * Determines if benefit is available for an account for dining. + * + * A value object. A strategy. Scoped by the Resturant aggregate. + */ +public interface BenefitAvailabilityPolicy { + + /** + * Calculates if an account is eligible to receive benefits for a dining. + * @param account the account of the member who dined + * @param dining the dining event + * @return benefit availability status + */ + boolean isBenefitAvailableFor(Account account, Dining dining); +} diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java new file mode 100644 index 0000000..62f11a6 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/JdbcRestaurantRepository.java @@ -0,0 +1,114 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import rewards.Dining; +import rewards.internal.account.Account; + +import javax.sql.DataSource; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Loads restaurants from a data source using the JDBC API. + */ +public class JdbcRestaurantRepository implements RestaurantRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcRestaurantRepository(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + */ + private final RowMapper rowMapper = new RestaurantRowMapper(); + + public Restaurant findByMerchantNumber(String merchantNumber) { + String sql = "select MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY from T_RESTAURANT where MERCHANT_NUMBER = ?"; + return jdbcTemplate.queryForObject(sql, rowMapper, merchantNumber); + } + + /** + * Maps a row returned from a query of T_RESTAURANT to a Restaurant object. + * + * @param rs the result set with its cursor positioned at the current row + */ + private Restaurant mapRestaurant(ResultSet rs) throws SQLException { + // get the row column data + String name = rs.getString("NAME"); + String number = rs.getString("MERCHANT_NUMBER"); + Percentage benefitPercentage = Percentage.valueOf(rs.getString("BENEFIT_PERCENTAGE")); + // map to the object + Restaurant restaurant = new Restaurant(number, name); + restaurant.setBenefitPercentage(benefitPercentage); + restaurant.setBenefitAvailabilityPolicy(mapBenefitAvailabilityPolicy(rs)); + return restaurant; + } + + /** + * Helper method that maps benefit availability policy data in the ResultSet to a fully-configured + * {@link BenefitAvailabilityPolicy} object. The key column is 'BENEFIT_AVAILABILITY_POLICY', which is a + * discriminator column containing a string code that identifies the type of policy. Currently supported types are: + * 'A' for 'always available' and 'N' for 'never available'. + * + *

+ * More types could be added easily by enhancing this method. For example, 'W' for 'Weekdays only' or 'M' for 'Max + * Rewards per Month'. Some of these types might require additional database column values to be configured, for + * example a 'MAX_REWARDS_PER_MONTH' data column. + * + * @param rs the result set used to map the policy object from database column values + * @return the matching benefit availability policy + * @throws IllegalArgumentException if the mapping could not be performed + */ + private BenefitAvailabilityPolicy mapBenefitAvailabilityPolicy(ResultSet rs) throws SQLException { + String policyCode = rs.getString("BENEFIT_AVAILABILITY_POLICY"); + if ("A".equals(policyCode)) { + return AlwaysAvailable.INSTANCE; + } else if ("N".equals(policyCode)) { + return NeverAvailable.INSTANCE; + } else { + throw new IllegalArgumentException("Not a supported policy code " + policyCode); + } + } + + /** + * Returns true indicating benefit is always available. + */ + static class AlwaysAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new AlwaysAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + + public String toString() { + return "alwaysAvailable"; + } + } + + /** + * Returns false indicating benefit is never available. + */ + static class NeverAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new NeverAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return false; + } + + public String toString() { + return "neverAvailable"; + } + } + + private class RestaurantRowMapper implements RowMapper { + + public Restaurant mapRow(ResultSet rs, int rowNum) throws SQLException { + return mapRestaurant(rs); + } + + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..50399f6 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,101 @@ +package rewards.internal.restaurant; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import common.repository.Entity; +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for dining based on an availability policy and a + * benefit percentage. + */ +public class Restaurant extends Entity { + + private String number; + + private String name; + + private Percentage benefitPercentage; + + private BenefitAvailabilityPolicy benefitAvailabilityPolicy; + + @SuppressWarnings("unused") + private Restaurant() { + } + + /** + * Creates a new restaurant. + * @param number the restaurant's merchant number + * @param name the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining transactions. + * @param benefitPercentage the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Sets the policy that determines if a dining by an account at this restaurant is eligible for benefit. + * @param benefitAvailabilityPolicy the benefit availability policy + */ + public void setBenefitAvailabilityPolicy(BenefitAvailabilityPolicy benefitAvailabilityPolicy) { + this.benefitAvailabilityPolicy = benefitAvailabilityPolicy; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Returns this restaurant's benefit availability policy. + */ + public BenefitAvailabilityPolicy getBenefitAvailabilityPolicy() { + return benefitAvailabilityPolicy; + } + + /** + * Calculate the benefit eligible to this account for dining at this restaurant. + * @param account the account that dined at this restaurant + * @param dining a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + if (benefitAvailabilityPolicy.isBenefitAvailableFor(account, dining)) { + return dining.getAmount().multiplyBy(benefitPercentage); + } else { + return MonetaryAmount.zero(); + } + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + "', benefitPercentage = " + benefitPercentage + + ", benefitAvailabilityPolicy = " + benefitAvailabilityPolicy; + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..6bad2ef --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,17 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/package.html b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..04f6502 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,36 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import org.springframework.jdbc.core.JdbcTemplate; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import javax.sql.DataSource; + +/** + * JDBC implementation of a reward repository that records the result of a reward transaction by inserting a reward + * confirmation record. + */ +public class JdbcRewardRepository implements RewardRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcRewardRepository(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + String confirmationNumber = nextConfirmationNumber(); + jdbcTemplate.update(sql, confirmationNumber, contribution.getAmount().asBigDecimal(), + SimpleDate.today().asDate(), contribution.getAccountNumber(), dining.getMerchantNumber(), + dining.getDate().asDate(), dining.getAmount().asBigDecimal()); + return new RewardConfirmation(confirmationNumber, contribution); + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + return jdbcTemplate.queryForObject(sql, String.class); + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/reward/package.html b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/32-jdbc-autoconfig/src/main/java/rewards/package.html b/lab/32-jdbc-autoconfig/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/32-jdbc-autoconfig/src/main/resources/application.properties b/lab/32-jdbc-autoconfig/src/main/resources/application.properties new file mode 100644 index 0000000..d375b37 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/resources/application.properties @@ -0,0 +1,22 @@ +# TO-DO-07: Uncomment this line so we can see what Spring Boot is +# doing internally +# debug=true + +rewards.recipient.name=John Doe +rewards.recipient.age=10 +rewards.recipient.gender=Male +rewards.recipient.hobby=Tennis + +# Logging control +logging.level.root=ERROR + +# TO-DO-12: Change logging level of 'config' package below to DEBUG +# - Rerun the 'RewardNetworkTests' test. +# - Do you see your datasource debug logging message? +# - Is DataSourceAutoConfiguration being excluded? +# (Search for "Exclusions" section in the log.) +# +logging.level.rewards=INFO +logging.level.config=INFO + + diff --git a/lab/32-jdbc-autoconfig/src/main/resources/data.sql b/lab/32-jdbc-autoconfig/src/main/resources/data.sql new file mode 100644 index 0000000..28a87cc --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/resources/data.sql @@ -0,0 +1,78 @@ + +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (17, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) + values ('1234567890', 'AppleBees', .08, 'A'); diff --git a/lab/32-jdbc-autoconfig/src/main/resources/schema.sql b/lab/32-jdbc-autoconfig/src/main/resources/schema.sql new file mode 100644 index 0000000..b0324fa --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/main/resources/schema.sql @@ -0,0 +1,20 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, unique(NUMBER)); +create table T_ACCOUNT_CREDIT_CARD (ID integer identity primary key, ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(3,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_CREDIT_CARD add constraint FK_ACCOUNT_CREDIT_CARD foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/test/java/rewards/RewardNetworkTests.java b/lab/32-jdbc-autoconfig/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..6578076 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,62 @@ +package rewards; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A system test that verifies the components of the RewardNetwork application work together to reward for dining + * successfully. Uses Spring to bootstrap the application for use in a test environment. + */ + +// TODO-09: Refactor this test code +// - Run this test without making any change, it will fail - think about why it fails +// - Replace the @ExtendWith and @ContextConfiguration annotations below with @SpringBootTest +// (There is no need to specify any configuration classes, +// because @SpringBootTest will find and use the configuration +// of RewardApplication automatically.) +// - Run the test, it should now pass. +// - Think about where auto-configuration is enabled for the test. +@SpringJUnitConfig(classes = {SystemTestConfig.class}) // Replace me +public class RewardNetworkTests { + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/test/java/rewards/SystemTestConfig.java b/lab/32-jdbc-autoconfig/src/test/java/rewards/SystemTestConfig.java new file mode 100644 index 0000000..564bccf --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/test/java/rewards/SystemTestConfig.java @@ -0,0 +1,38 @@ +package rewards; + +import config.RewardsConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +/** + * Sets up an embedded in-memory HSQL database, primarily for testing. + */ +@Configuration +@Import(RewardsConfig.class) +public class SystemTestConfig { + private final Logger logger = LoggerFactory.getLogger(SystemTestConfig.class); + + // TODO-08 : Use Spring Boot auto-configuration for DataSource + // - Note that this test configuration class is used by + // RewardNetworkTests in the same package + // - Comment out the @Bean method below so this method + // is no longer called + + @Bean + public DataSource dataSource() { + logger.debug("Creating the datasource bean explicitly"); + + return + (new EmbeddedDatabaseBuilder()) + .addScript("classpath:rewards/testdb/schema.sql") + .addScript("classpath:rewards/testdb/data.sql") + .build(); + } + +} diff --git a/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..77a6039 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,70 @@ +package rewards.internal; + +import common.money.MonetaryAmount; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/StubAccountRepository.java b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..41c4df5 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,41 @@ +package rewards.internal; + +import common.money.Percentage; +import org.springframework.dao.EmptyResultDataAccessException; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import java.util.HashMap; +import java.util.Map; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new EmptyResultDataAccessException(1); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..13993a9 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,51 @@ +package rewards.internal; + +import common.money.Percentage; +import org.springframework.dao.EmptyResultDataAccessException; +import rewards.Dining; +import rewards.internal.account.Account; +import rewards.internal.restaurant.BenefitAvailabilityPolicy; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import java.util.HashMap; +import java.util.Map; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new AlwaysReturnsTrue()); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new EmptyResultDataAccessException(1); + } + return restaurant; + } + + /** + * A simple "dummy" benefit availability policy that always returns true. Only useful for testing--a real + * availability policy might consider many factors such as the day of week of the dining, or the account's reward + * history for the current month. + */ + private static class AlwaysReturnsTrue implements BenefitAvailabilityPolicy { + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/StubRewardRepository.java b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..d149acc --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +import java.util.Random; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/account/AccountTests.java b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/account/AccountTests.java new file mode 100644 index 0000000..d2a53ae --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/account/AccountTests.java @@ -0,0 +1,53 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.Test; +import rewards.AccountContribution; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for the Account class that verify Account behavior works in isolation. + */ +public class AccountTests { + + private final Account account = new Account("1", "Keith and Keri Donald"); + + @Test + public void accountIsValid() { + // setup account with a valid set of beneficiaries to prepare for testing + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + assertTrue(account.isValid()); + } + + @Test + public void accountIsInvalidWithNoBeneficiaries() { + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreOver100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreUnder100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("25%")); + assertFalse(account.isValid()); + } + + @Test + public void makeContribution() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("100.00")); + assertEquals(contribution.getAmount(), MonetaryAmount.valueOf("100.00")); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java new file mode 100644 index 0000000..b1615ad --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/account/JdbcAccountRepositoryTests.java @@ -0,0 +1,95 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC account repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcAccountRepositoryTests { + + private JdbcAccountRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + repository = new JdbcAccountRepository(dataSource); + } + + @Test + public void testFindAccountByCreditCard() { + Account account = repository.findByCreditCard("1234123412341234"); + // assert the returned account contains what you expect given the state of the database + assertNotNull(account, "account should never be null"); + assertEquals(Long.valueOf(0), account.getEntityId(), "wrong entity id"); + assertEquals("123456789", account.getNumber(), "wrong account number"); + assertEquals("Keith and Keri Donald", account.getName(), "wrong name"); + assertEquals(2, account.getBeneficiaries().size(), "wrong beneficiary collection size"); + + Beneficiary b1 = account.getBeneficiary("Annabelle"); + assertNotNull(b1, "Annabelle should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b1.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b1.getAllocationPercentage(), "wrong allocation percentage"); + + Beneficiary b2 = account.getBeneficiary("Corgan"); + assertNotNull(b2, "Corgan should be a beneficiary"); + assertEquals(MonetaryAmount.valueOf("0.00"), b2.getSavings(), "wrong savings"); + assertEquals(Percentage.valueOf("50%"), b2.getAllocationPercentage(), "wrong allocation percentage"); + } + + @Test + public void testFindAccountByCreditCardNoAccount() { + assertThrows(EmptyResultDataAccessException.class, () -> { + repository.findByCreditCard("bogus"); + }); + } + + @Test + public void testUpdateBeneficiaries() throws SQLException { + Account account = repository.findByCreditCard("1234123412341234"); + account.makeContribution(MonetaryAmount.valueOf("8.00")); + repository.updateBeneficiaries(account); + verifyBeneficiaryTableUpdated(); + } + + private void verifyBeneficiaryTableUpdated() throws SQLException { + String sql = "select SAVINGS from T_ACCOUNT_BENEFICIARY where NAME = ? and ACCOUNT_ID = ?"; + PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql); + + // assert Annabelle has $4.00 savings now + stmt.setString(1, "Annabelle"); + stmt.setLong(2, 0L); + ResultSet rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + + // assert Corgan has $4.00 savings now + stmt.setString(1, "Corgan"); + stmt.setLong(2, 0L); + rs = stmt.executeQuery(); + rs.next(); + assertEquals(MonetaryAmount.valueOf("4.00"), MonetaryAmount.valueOf(rs.getString(1))); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java new file mode 100644 index 0000000..06ca758 --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/restaurant/JdbcRestaurantRepositoryTests.java @@ -0,0 +1,51 @@ +package rewards.internal.restaurant; + +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests the JDBC restaurant repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRestaurantRepositoryTests { + + private JdbcRestaurantRepository repository; + + @BeforeEach + public void setUp() { + repository = new JdbcRestaurantRepository(createTestDataSource()); + } + + @Test + public void testFindRestaurantByMerchantNumber() { + Restaurant restaurant = repository.findByMerchantNumber("1234567890"); + assertNotNull(restaurant, "the restaurant should never be null"); + assertEquals("1234567890", restaurant.getNumber(), "the merchant number is wrong"); + assertEquals("AppleBees", restaurant.getName(), "the name is wrong"); + assertEquals(Percentage.valueOf("8%"), restaurant.getBenefitPercentage(), "the benefitPercentage is wrong"); + assertEquals(JdbcRestaurantRepository.AlwaysAvailable.INSTANCE, + restaurant.getBenefitAvailabilityPolicy(), "the benefit availability policy is wrong"); + } + + @Test + public void testFindRestaurantByBogusMerchantNumber() { + assertThrows(EmptyResultDataAccessException.class, ()-> { + repository.findByMerchantNumber("bogus"); + }); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/restaurant/RestaurantTests.java b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/restaurant/RestaurantTests.java new file mode 100644 index 0000000..450e81c --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/restaurant/RestaurantTests.java @@ -0,0 +1,69 @@ +package rewards.internal.restaurant; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import rewards.Dining; +import rewards.internal.account.Account; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Unit tests for exercising the behavior of the Restaurant aggregate entity. A restaurant calculates a benefit to award + * to an account for dining based on an availability policy and benefit percentage. + */ +public class RestaurantTests { + + private Restaurant restaurant; + + private Account account; + + private Dining dining; + + @BeforeEach + public void setUp() { + // configure the restaurant, the object being tested + restaurant = new Restaurant("1234567890", "AppleBee's"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(true)); + // configure supporting objects needed by the restaurant + account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle"); + dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + } + + @Test + public void testCalcuateBenefitFor() { + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert 8.00 eligible for reward + assertEquals(MonetaryAmount.valueOf("8.00"), benefit); + } + + @Test + public void testNoBenefitAvailable() { + // configure stub that always returns false + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(false)); + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert zero eligible for reward + assertEquals(MonetaryAmount.valueOf("0.00"), benefit); + } + + /** + * A simple "dummy" benefit availability policy containing a single flag used to determine if benefit is available. + * Only useful for testing--a real availability policy might consider many factors such as the day of week of the + * dining, or the account's reward history for the current month. + */ + private static class StubBenefitAvailibilityPolicy implements BenefitAvailabilityPolicy { + + private final boolean isBenefitAvailable; + + public StubBenefitAvailibilityPolicy(boolean isBenefitAvailable) { + this.isBenefitAvailable = isBenefitAvailable; + } + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return isBenefitAvailable; + } + } +} \ No newline at end of file diff --git a/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java new file mode 100644 index 0000000..be8f7cc --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java @@ -0,0 +1,88 @@ +package rewards.internal.reward; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.Account; + +import javax.sql.DataSource; +import java.math.BigDecimal; +import java.sql.SQLException; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests the JDBC reward repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRewardRepositoryTests { + + private JdbcRewardRepository repository; + + private DataSource dataSource; + + private JdbcTemplate jdbcTemplate; + + @BeforeEach + public void setUp() { + dataSource = createTestDataSource(); + repository = new JdbcRewardRepository(dataSource); + jdbcTemplate = new JdbcTemplate(dataSource); + } + + @Test + public void testCreateReward() throws SQLException { + Dining dining = Dining.createDining("100.00", "1234123412341234", "0123456789"); + + Account account = new Account("1", "Keith and Keri Donald"); + account.setEntityId(0L); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("8.00")); + RewardConfirmation confirmation = repository.confirmReward(contribution, dining); + assertNotNull(confirmation, "confirmation should not be null"); + assertNotNull(confirmation.getConfirmationNumber(), "confirmation number should not be null"); + assertEquals(contribution, confirmation.getAccountContribution(), "wrong contribution object"); + verifyRewardInserted(confirmation, dining); + } + + private void verifyRewardInserted(RewardConfirmation confirmation, Dining dining) { + assertEquals(1, getRewardCount()); + String sql = "select * from T_REWARD where CONFIRMATION_NUMBER = ?"; + Map values = jdbcTemplate.queryForMap(sql, confirmation.getConfirmationNumber()); + verifyInsertedValues(confirmation, dining, values); + } + + private void verifyInsertedValues(RewardConfirmation confirmation, Dining dining, Map values) { + assertEquals(confirmation.getAccountContribution().getAmount(), new MonetaryAmount((BigDecimal) values + .get("REWARD_AMOUNT"))); + assertEquals(SimpleDate.today().asDate(), values.get("REWARD_DATE")); + assertEquals(confirmation.getAccountContribution().getAccountNumber(), values.get("ACCOUNT_NUMBER")); + assertEquals(dining.getAmount(), new MonetaryAmount((BigDecimal) values.get("DINING_AMOUNT"))); + assertEquals(dining.getMerchantNumber(), values.get("DINING_MERCHANT_NUMBER")); + assertEquals(SimpleDate.today().asDate(), values.get("DINING_DATE")); + } + + private int getRewardCount() { + String sql = "select count(*) from T_REWARD"; + return jdbcTemplate.queryForObject(sql, Integer.class); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/32-jdbc-autoconfig/src/test/resources/rewards/testdb/data.sql b/lab/32-jdbc-autoconfig/src/test/resources/rewards/testdb/data.sql new file mode 100644 index 0000000..28a87cc --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/test/resources/rewards/testdb/data.sql @@ -0,0 +1,78 @@ + +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (17, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) + values ('1234567890', 'AppleBees', .08, 'A'); diff --git a/lab/32-jdbc-autoconfig/src/test/resources/rewards/testdb/schema.sql b/lab/32-jdbc-autoconfig/src/test/resources/rewards/testdb/schema.sql new file mode 100644 index 0000000..b0324fa --- /dev/null +++ b/lab/32-jdbc-autoconfig/src/test/resources/rewards/testdb/schema.sql @@ -0,0 +1,20 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, unique(NUMBER)); +create table T_ACCOUNT_CREDIT_CARD (ID integer identity primary key, ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(3,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_CREDIT_CARD add constraint FK_ACCOUNT_CREDIT_CARD foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; \ No newline at end of file diff --git a/lab/33-autoconfig-helloworld-solution/.mvn/wrapper/MavenWrapperDownloader.java b/lab/33-autoconfig-helloworld-solution/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100644 index 0000000..fa4f7b4 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,110 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ + +import java.net.*; +import java.io.*; +import java.nio.channels.*; +import java.util.Properties; + +public class MavenWrapperDownloader { + + /** + * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. + */ + private static final String DEFAULT_DOWNLOAD_URL = + "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.2/maven-wrapper-0.4.2.jar"; + + /** + * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to + * use instead of the default one. + */ + private static final String MAVEN_WRAPPER_PROPERTIES_PATH = + ".mvn/wrapper/maven-wrapper.properties"; + + /** + * Path where the maven-wrapper.jar will be saved to. + */ + private static final String MAVEN_WRAPPER_JAR_PATH = + ".mvn/wrapper/maven-wrapper.jar"; + + /** + * Name of the property which should be used to override the default download url for the wrapper. + */ + private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; + + public static void main(String args[]) { + System.out.println("- Downloader started"); + File baseDirectory = new File(args[0]); + System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); + + // If the maven-wrapper.properties exists, read it and check if it contains a custom + // wrapperUrl parameter. + File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); + String url = DEFAULT_DOWNLOAD_URL; + if(mavenWrapperPropertyFile.exists()) { + FileInputStream mavenWrapperPropertyFileInputStream = null; + try { + mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); + Properties mavenWrapperProperties = new Properties(); + mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); + url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); + } catch (IOException e) { + System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); + } finally { + try { + if(mavenWrapperPropertyFileInputStream != null) { + mavenWrapperPropertyFileInputStream.close(); + } + } catch (IOException e) { + // Ignore ... + } + } + } + System.out.println("- Downloading from: : " + url); + + File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); + if(!outputFile.getParentFile().exists()) { + if(!outputFile.getParentFile().mkdirs()) { + System.out.println( + "- ERROR creating output direcrory '" + outputFile.getParentFile().getAbsolutePath() + "'"); + } + } + System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); + try { + downloadFileFromURL(url, outputFile); + System.out.println("Done"); + System.exit(0); + } catch (Throwable e) { + System.out.println("- Error downloading"); + e.printStackTrace(); + System.exit(1); + } + } + + private static void downloadFileFromURL(String urlString, File destination) throws Exception { + URL website = new URL(urlString); + ReadableByteChannel rbc; + rbc = Channels.newChannel(website.openStream()); + FileOutputStream fos = new FileOutputStream(destination); + fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + fos.close(); + rbc.close(); + } + +} diff --git a/lab/33-autoconfig-helloworld-solution/.mvn/wrapper/maven-wrapper.jar b/lab/33-autoconfig-helloworld-solution/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..c1dd12f Binary files /dev/null and b/lab/33-autoconfig-helloworld-solution/.mvn/wrapper/maven-wrapper.jar differ diff --git a/lab/33-autoconfig-helloworld-solution/.mvn/wrapper/maven-wrapper.properties b/lab/33-autoconfig-helloworld-solution/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..b74bf7f --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar diff --git a/lab/33-autoconfig-helloworld-solution/build.gradle b/lab/33-autoconfig-helloworld-solution/build.gradle new file mode 100644 index 0000000..c8af0e7 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/build.gradle @@ -0,0 +1,38 @@ +buildscript { + ext { + springBootVersion = "3.3.1" + } + + repositories { + mavenCentral() + } +} + +plugins { + id 'org.springframework.boot' version "$springBootVersion" +} + +subprojects { + apply plugin: 'java' + apply plugin: 'io.spring.dependency-management' + + group = 'com.example' + version = '0.0.1-SNAPSHOT' + sourceCompatibility = 17 + + repositories { + mavenCentral() + } + + dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:$springBootVersion") + } + } + + dependencies { + implementation "org.springframework.boot:spring-boot-starter" + testImplementation "org.springframework.boot:spring-boot-starter-test" + } +} + diff --git a/lab/33-autoconfig-helloworld-solution/gradle/wrapper/gradle-wrapper.jar b/lab/33-autoconfig-helloworld-solution/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/lab/33-autoconfig-helloworld-solution/gradle/wrapper/gradle-wrapper.jar differ diff --git a/lab/33-autoconfig-helloworld-solution/gradle/wrapper/gradle-wrapper.properties b/lab/33-autoconfig-helloworld-solution/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ae04661 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/lab/33-autoconfig-helloworld-solution/gradlew b/lab/33-autoconfig-helloworld-solution/gradlew new file mode 100755 index 0000000..a69d9cb --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/lab/33-autoconfig-helloworld-solution/gradlew.bat b/lab/33-autoconfig-helloworld-solution/gradlew.bat new file mode 100644 index 0000000..53a6b23 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lab/33-autoconfig-helloworld-solution/hello-app/build.gradle b/lab/33-autoconfig-helloworld-solution/hello-app/build.gradle new file mode 100644 index 0000000..147ff30 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-app/build.gradle @@ -0,0 +1,16 @@ +apply plugin: 'org.springframework.boot' + +dependencies { + // TO-DO-13: Study "hello-app" project. + // Note that it has dependency on "hello-lib" project + implementation project(':hello-starter') + // TO-DO-22: Replace compile project(':hello-lib') above with compile project(':hello-starter') +} + +test { + useJUnitPlatform() +} + + + + diff --git a/lab/33-autoconfig-helloworld-solution/hello-app/pom.xml b/lab/33-autoconfig-helloworld-solution/hello-app/pom.xml new file mode 100644 index 0000000..6d6a2b1 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-app/pom.xml @@ -0,0 +1,31 @@ + + + + 4.0.0 + hello-app + jar + 1.0 + + io.spring.training.core-spring + parentHelloServiceProject + 1.0 + + + UTF-8 + com.app.HelloApplication + + + + io.spring.training.core-spring + hello-starter + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/lab/33-autoconfig-helloworld-solution/hello-app/src/main/java/com/app/HelloApplication.java b/lab/33-autoconfig-helloworld-solution/hello-app/src/main/java/com/app/HelloApplication.java new file mode 100644 index 0000000..aefab16 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-app/src/main/java/com/app/HelloApplication.java @@ -0,0 +1,27 @@ +package com.app; + +import com.config.MyOwnConfig; +import com.lib.HelloService; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +@SpringBootApplication +@Import({MyOwnConfig.class}) +public class HelloApplication { + + public static void main(String[] args) { + SpringApplication.run(HelloApplication.class, args); + } + + + @Bean + public CommandLineRunner commandLineRunner( HelloService helloService) { + + return args -> helloService.greet(); + + } + +} diff --git a/lab/33-autoconfig-helloworld-solution/hello-app/src/main/java/com/app/MyOwnHelloService.java b/lab/33-autoconfig-helloworld-solution/hello-app/src/main/java/com/app/MyOwnHelloService.java new file mode 100644 index 0000000..23b62eb --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-app/src/main/java/com/app/MyOwnHelloService.java @@ -0,0 +1,10 @@ +package com.app; + +import com.lib.HelloService; + +public class MyOwnHelloService implements HelloService { + @Override + public void greet() { + System.out.println("My own service"); + } +} diff --git a/lab/33-autoconfig-helloworld-solution/hello-app/src/main/java/com/config/MyOwnConfig.java b/lab/33-autoconfig-helloworld-solution/hello-app/src/main/java/com/config/MyOwnConfig.java new file mode 100644 index 0000000..b92a9ce --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-app/src/main/java/com/config/MyOwnConfig.java @@ -0,0 +1,15 @@ +package com.config; + +import com.app.MyOwnHelloService; +import com.lib.HelloService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MyOwnConfig { + + @Bean + public HelloService helloService1() { + return new MyOwnHelloService(); + } +} diff --git a/lab/33-autoconfig-helloworld-solution/hello-app/src/main/resources/application.properties b/lab/33-autoconfig-helloworld-solution/hello-app/src/main/resources/application.properties new file mode 100644 index 0000000..8269185 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-app/src/main/resources/application.properties @@ -0,0 +1,6 @@ +# We want to see auto-configuration in action +debug=true + +# Bean overriding has to be enabled since Spring Boot 2.1. +# https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.1-Release-Notes +spring.main.allow-bean-definition-overriding=true \ No newline at end of file diff --git a/lab/33-autoconfig-helloworld-solution/hello-lib/build.gradle b/lab/33-autoconfig-helloworld-solution/hello-lib/build.gradle new file mode 100644 index 0000000..dfcc7df --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-lib/build.gradle @@ -0,0 +1 @@ +// no dependencies \ No newline at end of file diff --git a/lab/33-autoconfig-helloworld-solution/hello-lib/pom.xml b/lab/33-autoconfig-helloworld-solution/hello-lib/pom.xml new file mode 100644 index 0000000..906a70c --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-lib/pom.xml @@ -0,0 +1,13 @@ + + + + 4.0.0 + hello-lib + jar + 1.0 + + io.spring.training.core-spring + parentHelloServiceProject + 1.0 + + \ No newline at end of file diff --git a/lab/33-autoconfig-helloworld-solution/hello-lib/src/main/java/com/lib/HelloService.java b/lab/33-autoconfig-helloworld-solution/hello-lib/src/main/java/com/lib/HelloService.java new file mode 100644 index 0000000..33d91cc --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-lib/src/main/java/com/lib/HelloService.java @@ -0,0 +1,5 @@ +package com.lib; + +public interface HelloService { + void greet(); +} diff --git a/lab/33-autoconfig-helloworld-solution/hello-lib/src/main/java/com/lib/TypicalHelloService.java b/lab/33-autoconfig-helloworld-solution/hello-lib/src/main/java/com/lib/TypicalHelloService.java new file mode 100644 index 0000000..7018c29 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-lib/src/main/java/com/lib/TypicalHelloService.java @@ -0,0 +1,10 @@ +package com.lib; + +public class TypicalHelloService implements HelloService { + + @Override + public void greet() { + System.out.println("Hello, Typical!"); + } + +} diff --git a/lab/33-autoconfig-helloworld-solution/hello-starter/build.gradle b/lab/33-autoconfig-helloworld-solution/hello-starter/build.gradle new file mode 100644 index 0000000..b86ff3f --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-starter/build.gradle @@ -0,0 +1,6 @@ +apply plugin: 'java-library' + +dependencies { + api project(':hello-lib') +} + diff --git a/lab/33-autoconfig-helloworld-solution/hello-starter/pom.xml b/lab/33-autoconfig-helloworld-solution/hello-starter/pom.xml new file mode 100644 index 0000000..0fc36f9 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-starter/pom.xml @@ -0,0 +1,18 @@ + + + + 4.0.0 + hello-starter + jar + + io.spring.training.core-spring + parentHelloServiceProject + 1.0 + + + + io.spring.training.core-spring + hello-lib + + + \ No newline at end of file diff --git a/lab/33-autoconfig-helloworld-solution/hello-starter/src/main/java/com/starter/HelloAutoConfig.java b/lab/33-autoconfig-helloworld-solution/hello-starter/src/main/java/com/starter/HelloAutoConfig.java new file mode 100644 index 0000000..d087bc2 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-starter/src/main/java/com/starter/HelloAutoConfig.java @@ -0,0 +1,21 @@ +package com.starter; + +import com.lib.HelloService; +import com.lib.TypicalHelloService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +@Configuration +@ConditionalOnClass(HelloService.class) +public class HelloAutoConfig { + + @ConditionalOnMissingBean(HelloService.class) + @Bean + HelloService helloService() { + return new TypicalHelloService(); + } + +} diff --git a/lab/33-autoconfig-helloworld-solution/hello-starter/src/main/resources/META-INF/spring.factories b/lab/33-autoconfig-helloworld-solution/hello-starter/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-starter/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ + diff --git a/lab/33-autoconfig-helloworld-solution/hello-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/lab/33-autoconfig-helloworld-solution/hello-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..30b56db --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/hello-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.starter.HelloAutoConfig \ No newline at end of file diff --git a/lab/33-autoconfig-helloworld-solution/mvnw b/lab/33-autoconfig-helloworld-solution/mvnw new file mode 100755 index 0000000..8a8fb22 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/mvnw @@ -0,0 +1,316 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/lab/33-autoconfig-helloworld-solution/mvnw.cmd b/lab/33-autoconfig-helloworld-solution/mvnw.cmd new file mode 100644 index 0000000..1d8ab01 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/lab/33-autoconfig-helloworld-solution/pom.xml b/lab/33-autoconfig-helloworld-solution/pom.xml new file mode 100644 index 0000000..b6783fa --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/pom.xml @@ -0,0 +1,68 @@ + + 4.0.0 + io.spring.training.core-spring + parentHelloServiceProject + 1.0 + pom + + org.springframework.boot + spring-boot-starter-parent + 3.3.1 + + + + + 11 + + + 3.1.1 + + + + org.springframework.boot + spring-boot-starter + + + + + + + io.spring.training.core-spring + hello-lib + 1.0 + + + io.spring.training.core-spring + hello-starter + 1.0 + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + + + + + + + + hello-app + hello-starter + hello-lib + + diff --git a/lab/33-autoconfig-helloworld-solution/settings.gradle b/lab/33-autoconfig-helloworld-solution/settings.gradle new file mode 100644 index 0000000..12fffb6 --- /dev/null +++ b/lab/33-autoconfig-helloworld-solution/settings.gradle @@ -0,0 +1,10 @@ +// Part #1: "hello-app" uses "TypicalHelloService" bean from "hello-lib" project directly (tasks #11-16) +// By the way, assume "hello-lib" is 3rd-party library, which means you cannot change the source code +// Part #2: "hello-app" uses "TypicalHelloService" bean via "hello-starter" project and also +// creates and configures its own "MyOwnHelloService" bean (tasks #20-26) +// Part #3: Make "hello-starter" a Spring Boot starter with auto-configuration (tasks #30-38) +rootProject.name = 'parentHelloServiceProject' +include 'hello-app' +include 'hello-starter' +include 'hello-lib' + diff --git a/lab/33-autoconfig-helloworld/.mvn/wrapper/MavenWrapperDownloader.java b/lab/33-autoconfig-helloworld/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100644 index 0000000..fa4f7b4 --- /dev/null +++ b/lab/33-autoconfig-helloworld/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,110 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ + +import java.net.*; +import java.io.*; +import java.nio.channels.*; +import java.util.Properties; + +public class MavenWrapperDownloader { + + /** + * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. + */ + private static final String DEFAULT_DOWNLOAD_URL = + "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.4.2/maven-wrapper-0.4.2.jar"; + + /** + * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to + * use instead of the default one. + */ + private static final String MAVEN_WRAPPER_PROPERTIES_PATH = + ".mvn/wrapper/maven-wrapper.properties"; + + /** + * Path where the maven-wrapper.jar will be saved to. + */ + private static final String MAVEN_WRAPPER_JAR_PATH = + ".mvn/wrapper/maven-wrapper.jar"; + + /** + * Name of the property which should be used to override the default download url for the wrapper. + */ + private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; + + public static void main(String args[]) { + System.out.println("- Downloader started"); + File baseDirectory = new File(args[0]); + System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); + + // If the maven-wrapper.properties exists, read it and check if it contains a custom + // wrapperUrl parameter. + File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); + String url = DEFAULT_DOWNLOAD_URL; + if(mavenWrapperPropertyFile.exists()) { + FileInputStream mavenWrapperPropertyFileInputStream = null; + try { + mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); + Properties mavenWrapperProperties = new Properties(); + mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); + url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); + } catch (IOException e) { + System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); + } finally { + try { + if(mavenWrapperPropertyFileInputStream != null) { + mavenWrapperPropertyFileInputStream.close(); + } + } catch (IOException e) { + // Ignore ... + } + } + } + System.out.println("- Downloading from: : " + url); + + File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); + if(!outputFile.getParentFile().exists()) { + if(!outputFile.getParentFile().mkdirs()) { + System.out.println( + "- ERROR creating output direcrory '" + outputFile.getParentFile().getAbsolutePath() + "'"); + } + } + System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); + try { + downloadFileFromURL(url, outputFile); + System.out.println("Done"); + System.exit(0); + } catch (Throwable e) { + System.out.println("- Error downloading"); + e.printStackTrace(); + System.exit(1); + } + } + + private static void downloadFileFromURL(String urlString, File destination) throws Exception { + URL website = new URL(urlString); + ReadableByteChannel rbc; + rbc = Channels.newChannel(website.openStream()); + FileOutputStream fos = new FileOutputStream(destination); + fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + fos.close(); + rbc.close(); + } + +} diff --git a/lab/33-autoconfig-helloworld/.mvn/wrapper/maven-wrapper.jar b/lab/33-autoconfig-helloworld/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..c1dd12f Binary files /dev/null and b/lab/33-autoconfig-helloworld/.mvn/wrapper/maven-wrapper.jar differ diff --git a/lab/33-autoconfig-helloworld/.mvn/wrapper/maven-wrapper.properties b/lab/33-autoconfig-helloworld/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..b74bf7f --- /dev/null +++ b/lab/33-autoconfig-helloworld/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar diff --git a/lab/33-autoconfig-helloworld/build.gradle b/lab/33-autoconfig-helloworld/build.gradle new file mode 100644 index 0000000..c8af0e7 --- /dev/null +++ b/lab/33-autoconfig-helloworld/build.gradle @@ -0,0 +1,38 @@ +buildscript { + ext { + springBootVersion = "3.3.1" + } + + repositories { + mavenCentral() + } +} + +plugins { + id 'org.springframework.boot' version "$springBootVersion" +} + +subprojects { + apply plugin: 'java' + apply plugin: 'io.spring.dependency-management' + + group = 'com.example' + version = '0.0.1-SNAPSHOT' + sourceCompatibility = 17 + + repositories { + mavenCentral() + } + + dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:$springBootVersion") + } + } + + dependencies { + implementation "org.springframework.boot:spring-boot-starter" + testImplementation "org.springframework.boot:spring-boot-starter-test" + } +} + diff --git a/lab/33-autoconfig-helloworld/gradle/wrapper/gradle-wrapper.jar b/lab/33-autoconfig-helloworld/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/lab/33-autoconfig-helloworld/gradle/wrapper/gradle-wrapper.jar differ diff --git a/lab/33-autoconfig-helloworld/gradle/wrapper/gradle-wrapper.properties b/lab/33-autoconfig-helloworld/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ae04661 --- /dev/null +++ b/lab/33-autoconfig-helloworld/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/lab/33-autoconfig-helloworld/gradlew b/lab/33-autoconfig-helloworld/gradlew new file mode 100755 index 0000000..a69d9cb --- /dev/null +++ b/lab/33-autoconfig-helloworld/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/lab/33-autoconfig-helloworld/gradlew.bat b/lab/33-autoconfig-helloworld/gradlew.bat new file mode 100644 index 0000000..53a6b23 --- /dev/null +++ b/lab/33-autoconfig-helloworld/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lab/33-autoconfig-helloworld/hello-app/build.gradle b/lab/33-autoconfig-helloworld/hello-app/build.gradle new file mode 100644 index 0000000..f45a174 --- /dev/null +++ b/lab/33-autoconfig-helloworld/hello-app/build.gradle @@ -0,0 +1,18 @@ +apply plugin: 'org.springframework.boot' + +dependencies { + // TO-DO-13: Study "hello-app" project. + // Note that it has dependency on "hello-lib" project. + implementation project(':hello-lib') + // TO-DO-22: Replace implementation project(':hello-lib') above + // with implementation project(':hello-starter'). + // Make sure the IDE picks up the change in this file. +} + +test { + useJUnitPlatform() +} + + + + diff --git a/lab/33-autoconfig-helloworld/hello-app/pom.xml b/lab/33-autoconfig-helloworld/hello-app/pom.xml new file mode 100644 index 0000000..c6d5995 --- /dev/null +++ b/lab/33-autoconfig-helloworld/hello-app/pom.xml @@ -0,0 +1,35 @@ + + + + 4.0.0 + hello-app + jar + 1.0 + + io.spring.training.core-spring + parentHelloServiceProject + 1.0 + + + UTF-8 + com.app.HelloApplication + + + + + io.spring.training.core-spring + hello-lib + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/lab/33-autoconfig-helloworld/hello-app/src/main/java/com/app/HelloApplication.java b/lab/33-autoconfig-helloworld/hello-app/src/main/java/com/app/HelloApplication.java new file mode 100644 index 0000000..28460b7 --- /dev/null +++ b/lab/33-autoconfig-helloworld/hello-app/src/main/java/com/app/HelloApplication.java @@ -0,0 +1,132 @@ +package com.app; + +import com.lib.HelloService; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; + +// TODO-10: Go to TO-DO-10 in the setting.gradle file (Gradle) +// or in the root pom.xml (Maven) +// TODO-11: Go to TO-DO-11 in the same file + +// ------------------------------------------ + +// TODO-13: Go to TO-DO-13 in the build.gradle (Gradle) or +// pom.xml (Maven) of the "hello-app" project + +// ------------------------------------------ + +// TODO-20: Now we are ready to leverage "hello-starter" project. +// First, we want to configure "TypicalHelloService" bean +// via "hello-starter" project instead of directly from +// "hello-lib" project. +// +// TODO-21: Comment out the explicit @Bean configuration you just +// added in the previous step (TO-DO-16 below in this class) +// +// TODO-22: Go to TO-DO-22 in the build.gradle (Gradle) or +// pom.xml (Maven) of the "hello-app" project + +// ------------------------------------------ + +// +// TODO-25: Import the configuration class of "hello-starter" +// - Add @Import({HelloAutoConfig.class}) +// - Run the application +// - Note that The "HelloService" bean ("TypicalHelloService") is +// now contributed by the "hello-starter". +// At this point, "hello-starter" is nothing more than +// a library. It does not perform any auto-configuration yet. +// +// TODO-26: Now we are going to define application provided HelloService bean +// - Create "MyOwnHelloService" Bean under "com.app" directory +// - Create new configuration class called +// "MyOwnConfig" under "com.config" package +// - Configure "MyOwnHelloService" bean using @Bean method +// using "helloService" as name of the bean +// - Import "MyOwnConfig" configuration class by replacing +// existing @Import statement with +// @Import({MyOwnConfig.class, HelloAutoConfig.class}) +// - Run the application and observe that "TypicalHelloService" +// always wins. +// +// At this point, Spring picks up a bean that is loaded +// last, in this case, "TypicalHelloService" bean from the +// "HelloAutoConfig" class always wins because beans defined +// in the "HelloAutoConfig" are loaded last. +// +// - Change the order of bean loading by switching the +// two configuration classes like +// @Import({HelloAutoConfig.class, MyOwnConfig.class}) +// - Run the application. This time, you will always see +// "MyOwnHelloService" bean always wins. +// +// This is an example of "bean overloading". From Spring Boot 2.1 +// "bean overloading" is disabled by default and needs to be +// explicitly enabled. +// (See "application.properties" of "hello-app") +// +// - Once this step is done, go to TO-DO-30 below + +// ------------------------------------------ + +// TODO-30: Now we would like to change the behavior through +// auto-configuration so that the +// "TypicalHelloService" gets configured only when the +// application did not provide its "HelloService" bean. +// +// TODO-31: Remove "HelloAutoConfig.class" from @Import statement +// So now the import statement should look like +// @Import({MyOwnConfig.class}) +// +// TODO-32: Go to TO-DO-32 in the +// src/main/resources/META-INF/spring.factories file +// of the "hello-starter" project + +// ------------------------------------------ + +// TODO-35: Run the application again and see which one wins +// This time, "MyOwnHelloService" bean should win. +// +// TODO-36: In the console, search for "HelloAutoConfig" and +// see how auto-configuration is performed. +// You should see the one positive match and one negative match. +// +// TODO-37: Comment out @Import statement and run the application +// and observe that "TypicalHelloService" bean wins. +// (If it does not work, please do "./mvnw clean install" +// or "./gradlew clean build" in a terminal window, +// then run the application again.) +// +// TODO-38: In the console, search for "HelloAutoConfig" and +// see how auto-configuration is performed. +// This time, you should see the two positive matches. + +@SpringBootApplication +public class HelloApplication { + + public static void main(String[] args) { + SpringApplication.run(HelloApplication.class, args); + } + + // TODO-14: Review CommandLineRunner code below + // in which you are going to say greeting via + // injected HelloService + + // TODO-15: Run this application and you will experience a + // failure of "'HelloService' that could not be found" + + // TODO-16: Fix the problem (add @Bean definition of "HelloService" + // with Bean id "helloService") and run it again, + // verify it works. + // + // Once this step is done, go to TO-DO-20 + @Bean + public CommandLineRunner commandLineRunner(HelloService helloService) { + + return args -> helloService.greet(); + + } + +} diff --git a/lab/33-autoconfig-helloworld/hello-app/src/main/resources/application.properties b/lab/33-autoconfig-helloworld/hello-app/src/main/resources/application.properties new file mode 100644 index 0000000..8269185 --- /dev/null +++ b/lab/33-autoconfig-helloworld/hello-app/src/main/resources/application.properties @@ -0,0 +1,6 @@ +# We want to see auto-configuration in action +debug=true + +# Bean overriding has to be enabled since Spring Boot 2.1. +# https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.1-Release-Notes +spring.main.allow-bean-definition-overriding=true \ No newline at end of file diff --git a/lab/33-autoconfig-helloworld/hello-lib/build.gradle b/lab/33-autoconfig-helloworld/hello-lib/build.gradle new file mode 100644 index 0000000..dfcc7df --- /dev/null +++ b/lab/33-autoconfig-helloworld/hello-lib/build.gradle @@ -0,0 +1 @@ +// no dependencies \ No newline at end of file diff --git a/lab/33-autoconfig-helloworld/hello-lib/pom.xml b/lab/33-autoconfig-helloworld/hello-lib/pom.xml new file mode 100644 index 0000000..906a70c --- /dev/null +++ b/lab/33-autoconfig-helloworld/hello-lib/pom.xml @@ -0,0 +1,13 @@ + + + + 4.0.0 + hello-lib + jar + 1.0 + + io.spring.training.core-spring + parentHelloServiceProject + 1.0 + + \ No newline at end of file diff --git a/lab/33-autoconfig-helloworld/hello-lib/src/main/java/com/lib/HelloService.java b/lab/33-autoconfig-helloworld/hello-lib/src/main/java/com/lib/HelloService.java new file mode 100644 index 0000000..33d91cc --- /dev/null +++ b/lab/33-autoconfig-helloworld/hello-lib/src/main/java/com/lib/HelloService.java @@ -0,0 +1,5 @@ +package com.lib; + +public interface HelloService { + void greet(); +} diff --git a/lab/33-autoconfig-helloworld/hello-lib/src/main/java/com/lib/TypicalHelloService.java b/lab/33-autoconfig-helloworld/hello-lib/src/main/java/com/lib/TypicalHelloService.java new file mode 100644 index 0000000..1525077 --- /dev/null +++ b/lab/33-autoconfig-helloworld/hello-lib/src/main/java/com/lib/TypicalHelloService.java @@ -0,0 +1,14 @@ +package com.lib; + +// TODO-12: Study "hello-lib" project +// - It is just a library that contains this "TypicalHelloService" +// class and "HelloService" interface. +// - Consider this is a 3rd-party library, which you cannot change +public class TypicalHelloService implements HelloService { + + @Override + public void greet() { + System.out.println("Hello, Typical!"); + } + +} diff --git a/lab/33-autoconfig-helloworld/hello-starter/build.gradle b/lab/33-autoconfig-helloworld/hello-starter/build.gradle new file mode 100644 index 0000000..04cc52d --- /dev/null +++ b/lab/33-autoconfig-helloworld/hello-starter/build.gradle @@ -0,0 +1,10 @@ +apply plugin: 'java-library' + +dependencies { + // TODO-23: Note that hello-starter has a transitive dependency on + // hello-lib. This is similar to the way that many Spring Boot + // starters have dependencies on the library (or libraries) + // that they configure. + api project(':hello-lib') +} + diff --git a/lab/33-autoconfig-helloworld/hello-starter/pom.xml b/lab/33-autoconfig-helloworld/hello-starter/pom.xml new file mode 100644 index 0000000..0fc36f9 --- /dev/null +++ b/lab/33-autoconfig-helloworld/hello-starter/pom.xml @@ -0,0 +1,18 @@ + + + + 4.0.0 + hello-starter + jar + + io.spring.training.core-spring + parentHelloServiceProject + 1.0 + + + + io.spring.training.core-spring + hello-lib + + + \ No newline at end of file diff --git a/lab/33-autoconfig-helloworld/hello-starter/src/main/java/com/starter/HelloAutoConfig.java b/lab/33-autoconfig-helloworld/hello-starter/src/main/java/com/starter/HelloAutoConfig.java new file mode 100644 index 0000000..5fe290c --- /dev/null +++ b/lab/33-autoconfig-helloworld/hello-starter/src/main/java/com/starter/HelloAutoConfig.java @@ -0,0 +1,32 @@ +package com.starter; + +import com.lib.HelloService; +import com.lib.TypicalHelloService; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +// TODO-24: Note that "hello-starter" has its own configuration class, +// in which "TypicalHelloService" bean is configured. +@AutoConfiguration +@Configuration + +// --------------------------------------------- + +// TODO-33: Add @ConditionalOnClass(HelloService.class) to the class +// - This will make sure this auto-configuration class is used only +// when the "HelloService.class" is in the classpath + +public class HelloAutoConfig { + + // TODO-34: Add @ConditionalOnMissingBean(HelloService.class) to the method + // - This will make sure this "HelloService" bean will be auto-configured + // only when there is no "HelloService" bean already in the application + // context + @Bean + HelloService helloService() { + return new TypicalHelloService(); + } + +} + diff --git a/lab/33-autoconfig-helloworld/hello-starter/src/main/resources/META-INF/spring.factories b/lab/33-autoconfig-helloworld/hello-starter/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lab/33-autoconfig-helloworld/hello-starter/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ + diff --git a/lab/33-autoconfig-helloworld/hello-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/lab/33-autoconfig-helloworld/hello-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..8670557 --- /dev/null +++ b/lab/33-autoconfig-helloworld/hello-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +# TO-DO-32: Add "com.starter.HelloAutoConfig" (without quotes) +# Now Spring Boot will use "com.starter.HelloAutoConfig" +# as part of its auto-configuration. \ No newline at end of file diff --git a/lab/33-autoconfig-helloworld/mvnw b/lab/33-autoconfig-helloworld/mvnw new file mode 100755 index 0000000..8a8fb22 --- /dev/null +++ b/lab/33-autoconfig-helloworld/mvnw @@ -0,0 +1,316 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/lab/33-autoconfig-helloworld/mvnw.cmd b/lab/33-autoconfig-helloworld/mvnw.cmd new file mode 100644 index 0000000..1d8ab01 --- /dev/null +++ b/lab/33-autoconfig-helloworld/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/lab/33-autoconfig-helloworld/pom.xml b/lab/33-autoconfig-helloworld/pom.xml new file mode 100644 index 0000000..b6783fa --- /dev/null +++ b/lab/33-autoconfig-helloworld/pom.xml @@ -0,0 +1,68 @@ + + 4.0.0 + io.spring.training.core-spring + parentHelloServiceProject + 1.0 + pom + + org.springframework.boot + spring-boot-starter-parent + 3.3.1 + + + + + 11 + + + 3.1.1 + + + + org.springframework.boot + spring-boot-starter + + + + + + + io.spring.training.core-spring + hello-lib + 1.0 + + + io.spring.training.core-spring + hello-starter + 1.0 + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + + + + + + + + hello-app + hello-starter + hello-lib + + diff --git a/lab/33-autoconfig-helloworld/settings.gradle b/lab/33-autoconfig-helloworld/settings.gradle new file mode 100644 index 0000000..39f2c95 --- /dev/null +++ b/lab/33-autoconfig-helloworld/settings.gradle @@ -0,0 +1,13 @@ +// TO-DO-10: This lab is made of 3 parts +// Part #1: "hello-app" uses "TypicalHelloService" bean from "hello-lib" project directly (tasks #11-16) +// By the way, assume "hello-lib" is 3rd-party library, which means you cannot change the source code +// Part #2: "hello-app" uses "TypicalHelloService" bean via "hello-starter" project and also +// creates and configures its own "MyOwnHelloService" bean (tasks #20-26) +// Part #3: Make "hello-starter" a Spring Boot starter with auto-configuration (tasks #30-38) + +// TO-DO-11: Note that there are three sub-projects +rootProject.name = 'parentHelloServiceProject' +include 'hello-app' +include 'hello-starter' +include 'hello-lib' + diff --git a/lab/34-spring-data-jpa-solution/build.gradle b/lab/34-spring-data-jpa-solution/build.gradle new file mode 100644 index 0000000..0d9d9c1 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/build.gradle @@ -0,0 +1,6 @@ +apply plugin: "org.springframework.boot" + +dependencies { + implementation project(':00-rewards-common') + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' +} diff --git a/lab/34-spring-data-jpa-solution/pom.xml b/lab/34-spring-data-jpa-solution/pom.xml new file mode 100644 index 0000000..7ba88a0 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + 34-spring-data-jpa-solution + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.hsqldb + hsqldb + + + diff --git a/lab/34-spring-data-jpa-solution/src/main/java/config/RewardsConfig.java b/lab/34-spring-data-jpa-solution/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..ff95870 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/config/RewardsConfig.java @@ -0,0 +1,42 @@ +package config; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import rewards.RewardNetwork; +import rewards.internal.RewardNetworkImpl; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.JdbcRewardRepository; +import rewards.internal.reward.RewardRepository; + +@Configuration +public class RewardsConfig { + + @Autowired + DataSource dataSource; + + @Bean + public RewardNetwork rewardNetwork( + AccountRepository accountRepository, + RestaurantRepository restaurantRepository, + RewardRepository rewardRepository ) { + return new RewardNetworkImpl( + accountRepository, + restaurantRepository, + rewardRepository); + } + + @Bean + public RewardRepository rewardRepository(){ + JdbcRewardRepository repository = new JdbcRewardRepository(); + repository.setDataSource(dataSource); + return repository; + } + +} diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/AccountContribution.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/Dining.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/RewardConfirmation.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/RewardNetwork.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/RewardsApplication.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/RewardsApplication.java new file mode 100644 index 0000000..ca0d97c --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/RewardsApplication.java @@ -0,0 +1,42 @@ +package rewards; + +import config.RewardsConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +@SpringBootApplication +@Import(RewardsConfig.class) +public class RewardsApplication { + private final Logger logger + = LoggerFactory.getLogger(RewardsApplication.class); + + private static final String SQL = "SELECT count(*) FROM T_ACCOUNT"; + + public static void main(String[] args) { + SpringApplication.run(RewardsApplication.class,args); + } + + @Component + public final class QueryAccountCountRunner + implements CommandLineRunner { + + private final JdbcTemplate jdbcTemplate; + + public QueryAccountCountRunner(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public void run(String... args) { + long accountCount + = this.jdbcTemplate.queryForObject(SQL, Long.class); + logger.info("Number of accounts: {}", accountCount); + } + } +} diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..40b10b5 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,54 @@ +package rewards.internal; + +import org.springframework.transaction.annotation.Transactional; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + @Transactional + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCardNumber(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/account/Account.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..9aab519 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,209 @@ +package rewards.internal.account; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * An account for a member of the reward network. An account has one or more beneficiaries whose allocations must add up + * to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is distributed among the beneficiaries + * based on an allocation. + * + * An entity. An aggregate. + */ +@Entity +@Table(name="T_ACCOUNT") +public class Account { + + @Id + @Column(name="id") + private Long entityId; + + // No need for @Column, mapped automatically to NUMBER + private String number; + + // No need for @Column, mapped automatically to NAME + private String name; + + @OneToMany + @JoinColumn(name="ACCOUNT_ID") + private Set beneficiaries = new HashSet<>(); + + @Column(name="CREDIT_CARD") + private String creditCardNumber; + + public Account() { + } + + /** + * Create a new account. + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Getter for the credit card number for this account. + * + * @return the credit card number for this account as a 16-character String. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Setter for the credit card number for this account. + * + * @param creditCardNumber + */ + public void setCreditCardNumber(String creditCardNumber) { + this.creditCardNumber = creditCardNumber; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Returns the id for this account. + */ + public Long getEntityId() { + return entityId; + } + + /** + * Sets the id for this account. Package local - only available to tests. + */ + void setEntityId(Long entityId) { + this.entityId = entityId; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + try { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } catch (IllegalArgumentException e) { + // total would have been over 100% - return invalid + return false; + } + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is distributed among the account's + * beneficiaries based on each beneficiary's allocation percentage. + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, beneficiary + .getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. Callers should not attempt to hold on or modify the returned set. + * This method should only be used transitively; for example, called to facilitate account reporting. + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Returns a single account beneficiary. Callers should not attempt to hold on or modify the returned object. This + * method should only be used transitively; for example, called to facilitate reporting or testing. + * @param name the name of the beneficiary e.g "Annabelle" + * @return the beneficiary object + */ + public Beneficiary getBeneficiary(String name) { + for (Beneficiary b : beneficiaries) { + if (b.getName().equals(name)) { + return b; + } + } + throw new IllegalArgumentException("No such beneficiary with name '" + name + "'"); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the repository responsible for reconstituting + * this account. + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/account/AccountRepository.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..43d8871 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,24 @@ +package rewards.internal.account; + +import org.springframework.data.repository.Repository; + +/** + * Loads account aggregates. Called by the reward network to find and + * reconstitute Account entities from an external form such as a set of RDMS + * rows. + * + * Objects returned by this repository are guaranteed to be fully initialized + * and ready to use. + */ +public interface AccountRepository extends Repository { + + /** + * Load an account by its credit card. + * + * @param creditCardNumber + * the credit card number + * @return the account object + */ + Account findByCreditCardNumber(String creditCardNumber); + +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/account/Beneficiary.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..0b4633a --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,99 @@ +package rewards.internal.account; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +@Entity +@Table(name="T_ACCOUNT_BENEFICIARY") +public class Beneficiary { + + @Id + @Column(name="ID") + private Long entityId; + + // No need for @Column, mapped automatically to NAME + private String name; + + @AttributeOverride(name="value",column=@Column(name="ALLOCATION_PERCENTAGE")) + private Percentage allocationPercentage; + + @AttributeOverride(name="value",column=@Column(name="SAVINGS")) + private MonetaryAmount savings = MonetaryAmount.zero(); + + public Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Returns the id for this beneficiary. + */ + public Long getEntityId() { + return entityId; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/account/package.html b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/package.html b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/AlwaysAvailable.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/AlwaysAvailable.java new file mode 100644 index 0000000..f97fbeb --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/AlwaysAvailable.java @@ -0,0 +1,19 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * A benefit availabilty policy that returns true at all times. + */ +public class AlwaysAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new AlwaysAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + + public String toString() { + return "alwaysAvailable"; + } +} diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java new file mode 100644 index 0000000..b7d6d74 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java @@ -0,0 +1,20 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * Determines if benefit is available for an account for dining. + * + * A value object. A strategy. Scoped by the Resturant aggregate. + */ +public interface BenefitAvailabilityPolicy { + + /** + * Calculates if an account is eligible to receive benefits for a dining. + * @param account the account of the member who dined + * @param dining the dining event + * @return benefit availability status + */ + boolean isBenefitAvailableFor(Account account, Dining dining); +} diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/NeverAvailable.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/NeverAvailable.java new file mode 100644 index 0000000..8f6cbc3 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/NeverAvailable.java @@ -0,0 +1,19 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * A benefit availabilty policy that returns false at all times. + */ +public class NeverAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new NeverAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return false; + } + + public String toString() { + return "neverAvailable"; + } +} diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..753882f --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,177 @@ +package rewards.internal.restaurant; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A restaurant establishment in the network. Like AppleBee's. + * + * Restaurants calculate how much benefit may be awarded to an account for + * dining based on an availability policy and a benefit percentage. + */ +@Entity +@Table(name = "T_RESTAURANT") +public class Restaurant { + + @Id + @Column(name = "id") + private Long entityId; + + @Column(name = "MERCHANT_NUMBER") + private String number; + + // No need for @Column, mapped automatically to NAME + private String name; + + @AttributeOverride(name="value",column=@Column(name="BENEFIT_PERCENTAGE")) + private Percentage benefitPercentage; + + + // DO NOT map this field. For now it is always set to AlwaysAvailable. + // The bonus section later will redo this mapping. + @Transient + private BenefitAvailabilityPolicy benefitAvailabilityPolicy = AlwaysAvailable.INSTANCE; + + + public Restaurant() { + //Needed by the JPA spec + } + + /** + * Creates a new restaurant. + * + * @param number + * the restaurant's merchant number + * @param name + * the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining + * transactions. + * + * @param benefitPercentage + * the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Sets the policy that determines if a dining by an account at this + * restaurant is eligible for benefit. + * + * @param benefitAvailabilityPolicy + * the benefit availability policy + */ + public void setBenefitAvailabilityPolicy( + BenefitAvailabilityPolicy benefitAvailabilityPolicy) { + this.benefitAvailabilityPolicy = benefitAvailabilityPolicy; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Returns this restaurant's benefit availability policy. + */ + public BenefitAvailabilityPolicy getBenefitAvailabilityPolicy() { + return benefitAvailabilityPolicy; + } + + /** + * Returns the id for this restaurant. + */ + public Long getEntityId() { + return entityId; + } + + /** + * Calculate the benefit eligible to this account for dining at this + * restaurant. + * + * @param account + * the account that dined at this restaurant + * @param dining + * a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + if (benefitAvailabilityPolicy.isBenefitAvailableFor(account, dining)) { + return dining.getAmount().multiplyBy(benefitPercentage); + } else { + return MonetaryAmount.zero(); + } + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + + "', benefitPercentage = " + benefitPercentage + + ", benefitAvailabilityPolicy = " + benefitAvailabilityPolicy; + } + + // Internal methods for JPA only - hence they are protected. + /** + * Sets this restaurant's benefit availability policy from the code stored + * in the underlying column. This is a database specific accessor using the + * JPA 2 @Access annotation. + */ + protected void setDbBenefitAvailabilityPolicy(String policyCode) { + if ("A".equals(policyCode)) { + benefitAvailabilityPolicy = AlwaysAvailable.INSTANCE; + } else if ("N".equals(policyCode)) { + benefitAvailabilityPolicy = NeverAvailable.INSTANCE; + } else { + throw new IllegalArgumentException("Not a supported policy code " + + policyCode); + } + } + + /** + * Returns this restaurant's benefit availability policy code for storage in + * the underlying column. This is a database specific accessor using the JPA + * 2 @Access annotation. + */ + protected String getDbBenefitAvailabilityPolicy() { + if (benefitAvailabilityPolicy == AlwaysAvailable.INSTANCE) { + return "A"; + } else if (benefitAvailabilityPolicy == NeverAvailable.INSTANCE) { + return "N"; + } else { + throw new IllegalArgumentException("No policy code for " + + benefitAvailabilityPolicy.getClass()); + } + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..a2eebb4 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,21 @@ +package rewards.internal.restaurant; + +import org.springframework.data.repository.Repository; + +/** + * Loads restaurant aggregates. Called by the reward network to find + * and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be + * fully-initialized and ready to use. + */ +public interface RestaurantRepository extends Repository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + Restaurant findByNumber(String merchantNumber); +} diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/package.html b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..e5ae22b --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,38 @@ +package rewards.internal.reward; + +import javax.sql.DataSource; + +import org.springframework.jdbc.core.JdbcTemplate; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import common.datetime.SimpleDate; + +/** + * JDBC implementation of a reward repository that records the result of a reward transaction by inserting a reward + * confirmation record. + */ +public class JdbcRewardRepository implements RewardRepository { + + private JdbcTemplate jdbcTemplate; + + public void setDataSource(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + String confirmationNumber = nextConfirmationNumber(); + jdbcTemplate.update(sql, confirmationNumber, contribution.getAmount().asBigDecimal(), + SimpleDate.today().asDate(), contribution.getAccountNumber(), dining.getMerchantNumber(), + dining.getDate().asDate(), dining.getAmount().asBigDecimal()); + return new RewardConfirmation(confirmationNumber, contribution); + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + return jdbcTemplate.queryForObject(sql, String.class); + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/reward/package.html b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/34-spring-data-jpa-solution/src/main/java/rewards/package.html b/lab/34-spring-data-jpa-solution/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/34-spring-data-jpa-solution/src/main/resources/application.properties b/lab/34-spring-data-jpa-solution/src/main/resources/application.properties new file mode 100644 index 0000000..3ae7a55 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/resources/application.properties @@ -0,0 +1,4 @@ +logging.level.root=INFO + +# Don't need Hibernate to populate the database, our scripts did it already +spring.jpa.hibernate.ddl-auto=none \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/main/resources/data.sql b/lab/34-spring-data-jpa-solution/src/main/resources/data.sql new file mode 100644 index 0000000..cf0750b --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/resources/data.sql @@ -0,0 +1,77 @@ +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (17, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) +values ('1234567890', 'AppleBees', .08, 'A'); diff --git a/lab/34-spring-data-jpa-solution/src/main/resources/schema.sql b/lab/34-spring-data-jpa-solution/src/main/resources/schema.sql new file mode 100644 index 0000000..cc253d5 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/main/resources/schema.sql @@ -0,0 +1,20 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, unique(NUMBER)); +create table T_ACCOUNT_CREDIT_CARD (ID integer identity primary key, ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(3,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_CREDIT_CARD add constraint FK_ACCOUNT_CREDIT_CARD foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; diff --git a/lab/34-spring-data-jpa-solution/src/test/java/rewards/RewardNetworkTests.java b/lab/34-spring-data-jpa-solution/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..4146eb6 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,57 @@ +package rewards; + +import common.money.MonetaryAmount; +import config.RewardsConfig; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A system test that verifies the components of the RewardNetwork application + * work together to reward for dining successfully. Uses Spring to bootstrap the + * application for use in a test environment. + */ +@SpringBootTest +public class RewardNetworkTests { + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by + // merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..98b7353 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,72 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +import common.money.MonetaryAmount; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/StubAccountRepository.java b/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..2dcf169 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,43 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.orm.ObjectRetrievalFailureException; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCardNumber(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new ObjectRetrievalFailureException(Account.class, creditCardNumber); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..b62327d --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,53 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.orm.ObjectRetrievalFailureException; + +import rewards.Dining; +import rewards.internal.account.Account; +import rewards.internal.restaurant.BenefitAvailabilityPolicy; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new AlwaysReturnsTrue()); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new ObjectRetrievalFailureException(Restaurant.class, merchantNumber); + } + return restaurant; + } + + /** + * A simple "dummy" benefit availability policy that always returns true. Only useful for testing--a real + * availability policy might consider many factors such as the day of week of the dining, or the account's reward + * history for the current month. + */ + private static class AlwaysReturnsTrue implements BenefitAvailabilityPolicy { + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/StubRewardRepository.java b/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..2487aca --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import java.util.Random; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/account/AccountTests.java b/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/account/AccountTests.java new file mode 100644 index 0000000..4075654 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/account/AccountTests.java @@ -0,0 +1,57 @@ +package rewards.internal.account; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for the Account class that verify Account behavior works in isolation. + */ +public class AccountTests { + + private final Account account = new Account("1", "Keith and Keri Donald"); + + @Test + public void accountIsValid() { + // setup account with a valid set of beneficiaries to prepare for testing + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + assertTrue(account.isValid()); + } + + @Test + public void accountIsInvalidWithNoBeneficiaries() { + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreOver100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreUnder100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("25%")); + assertFalse(account.isValid()); + } + + @Test + public void makeContribution() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("100.00")); + assertEquals(contribution.getAmount(), MonetaryAmount.valueOf("100.00")); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java b/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java new file mode 100644 index 0000000..fcc58c2 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/restaurant/RestaurantTests.java @@ -0,0 +1,71 @@ +package rewards.internal.restaurant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for exercising the behavior of the Restaurant aggregate entity. A restaurant calculates a benefit to award + * to an account for dining based on an availability policy and benefit percentage. + */ +public class RestaurantTests { + + private Restaurant restaurant; + + private Account account; + + private Dining dining; + + @BeforeEach + public void setUp() { + // configure the restaurant, the object being tested + restaurant = new Restaurant("1234567890", "AppleBee's"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(true)); + // configure supporting objects needed by the restaurant + account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle"); + dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + } + + @Test + public void testCalcuateBenefitFor() { + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert 8.00 eligible for reward + assertEquals(MonetaryAmount.valueOf("8.00"), benefit); + } + + @Test + public void testNoBenefitAvailable() { + // configure stub that always returns false + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(false)); + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert zero eligible for reward + assertEquals(MonetaryAmount.valueOf("0.00"), benefit); + } + + /** + * A simple "dummy" benefit availability policy containing a single flag used to determine if benefit is available. + * Only useful for testing - a real availability policy might consider many factors such as the day of week of the + * dining, or the account's reward history for the current month. + */ + private static class StubBenefitAvailibilityPolicy implements BenefitAvailabilityPolicy { + + private final boolean isBenefitAvailable; + + public StubBenefitAvailibilityPolicy(boolean isBenefitAvailable) { + this.isBenefitAvailable = isBenefitAvailable; + } + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return isBenefitAvailable; + } + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java b/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java new file mode 100644 index 0000000..880c221 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java @@ -0,0 +1,80 @@ +package rewards.internal.reward; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.Account; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Tests the JDBC reward repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRewardRepositoryTests { + + private JdbcRewardRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + repository = new JdbcRewardRepository(); + dataSource = createTestDataSource(); + repository.setDataSource(dataSource); + } + + @Test + public void testCreateReward() throws SQLException { + Dining dining = Dining.createDining("100.00", "1234123412341234", "0123456789"); + + Account account = new Account("1", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("8.00")); + RewardConfirmation confirmation = repository.confirmReward(contribution, dining); + assertNotNull(confirmation, "confirmation should not be null"); + assertNotNull("confirmation number should not be null", confirmation.getConfirmationNumber()); + assertEquals(contribution, confirmation.getAccountContribution(), "wrong contribution object"); + verifyRewardInserted(confirmation, dining); + } + + private void verifyRewardInserted(RewardConfirmation confirmation, Dining dining) throws SQLException { + assertEquals(1, getRewardCount()); + Statement stmt = dataSource.getConnection().createStatement(); + ResultSet rs = stmt.executeQuery("select REWARD_AMOUNT from T_REWARD where CONFIRMATION_NUMBER = '" + + confirmation.getConfirmationNumber() + "'"); + rs.next(); + assertEquals(confirmation.getAccountContribution().getAmount(), MonetaryAmount.valueOf(rs.getString(1))); + } + + private int getRewardCount() throws SQLException { + Statement stmt = dataSource.getConnection().createStatement(); + ResultSet rs = stmt.executeQuery("select count(*) from T_REWARD"); + rs.next(); + return rs.getInt(1); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/34-spring-data-jpa-solution/src/test/resources/application.properties b/lab/34-spring-data-jpa-solution/src/test/resources/application.properties new file mode 100644 index 0000000..7f87e51 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/test/resources/application.properties @@ -0,0 +1,10 @@ +# Populate the test database with test data +spring.sql.init.schema-locations=classpath:/rewards/testdb/test-schema.sql +spring.sql.init.data-locations=classpath:/rewards/testdb/test-data.sql + +# Setup JPA +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +# Don't need Hibernate to populate the database, our scripts did it already +spring.jpa.hibernate.ddl-auto=none diff --git a/lab/34-spring-data-jpa-solution/src/test/resources/rewards/testdb/test-data.sql b/lab/34-spring-data-jpa-solution/src/test/resources/rewards/testdb/test-data.sql new file mode 100644 index 0000000..3fd3972 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/test/resources/rewards/testdb/test-data.sql @@ -0,0 +1,6 @@ +insert into T_ACCOUNT (NUMBER, NAME, CREDIT_CARD) values ('123456789', 'Keith and Keri Donald', '1234123412341234'); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) values (0, 'Annabelle', 0.50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) values (0, 'Corgan', 0.50, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) values ('1234567890', 'AppleBees', 0.08, 'A'); +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) values ('1234567891', 'Barnabees', 1.00, 'N'); \ No newline at end of file diff --git a/lab/34-spring-data-jpa-solution/src/test/resources/rewards/testdb/test-schema.sql b/lab/34-spring-data-jpa-solution/src/test/resources/rewards/testdb/test-schema.sql new file mode 100644 index 0000000..d971e97 --- /dev/null +++ b/lab/34-spring-data-jpa-solution/src/test/resources/rewards/testdb/test-schema.sql @@ -0,0 +1,18 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, CREDIT_CARD varchar(16), unique(NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(5,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(5,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal(8,2) not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; \ No newline at end of file diff --git a/lab/34-spring-data-jpa/build.gradle b/lab/34-spring-data-jpa/build.gradle new file mode 100644 index 0000000..785a361 --- /dev/null +++ b/lab/34-spring-data-jpa/build.gradle @@ -0,0 +1,14 @@ +apply plugin: "org.springframework.boot" + +dependencies { + implementation project(':00-rewards-common') + // TO-DO-01 : Review the dependency for the Spring Boot Data JPA Starter. + // What does this do? + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' +} + +test { + // TODO-08 comment out before running the RewardNetworkTests + exclude '**/RewardNetworkTests.class' +} + diff --git a/lab/34-spring-data-jpa/pom.xml b/lab/34-spring-data-jpa/pom.xml new file mode 100644 index 0000000..12e0b0e --- /dev/null +++ b/lab/34-spring-data-jpa/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + 34-spring-data-jpa + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + + io.spring.training.core-spring + 00-rewards-common + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.hsqldb + hsqldb + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/RewardNetworkTests.java + + + + + + diff --git a/lab/34-spring-data-jpa/src/main/java/config/RewardsConfig.java b/lab/34-spring-data-jpa/src/main/java/config/RewardsConfig.java new file mode 100644 index 0000000..30cf26f --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/config/RewardsConfig.java @@ -0,0 +1,41 @@ +package config; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import rewards.RewardNetwork; +import rewards.internal.RewardNetworkImpl; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.JdbcRewardRepository; +import rewards.internal.reward.RewardRepository; + +@Configuration +public class RewardsConfig { + + @Autowired + DataSource dataSource; + + @Bean + public RewardNetwork rewardNetwork( + AccountRepository accountRepository, + RestaurantRepository restaurantRepository, + RewardRepository rewardRepository ) { + return new RewardNetworkImpl( + accountRepository, + restaurantRepository, + rewardRepository); + } + + @Bean + public RewardRepository rewardRepository(){ + JdbcRewardRepository repository = new JdbcRewardRepository(); + repository.setDataSource(dataSource); + return repository; + } + +} diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/AccountContribution.java b/lab/34-spring-data-jpa/src/main/java/rewards/AccountContribution.java new file mode 100644 index 0000000..5cad191 --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/AccountContribution.java @@ -0,0 +1,138 @@ +package rewards; + +import java.util.Set; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A summary of a monetary contribution made to an account that was distributed among the account's beneficiaries. + * + * A value object. Immutable. + */ +public class AccountContribution { + + private String accountNumber; + + private MonetaryAmount amount; + + private Set distributions; + + /** + * Creates a new account contribution. + * @param accountNumber the number of the account the contribution was made + * @param amount the total contribution amount + * @param distributions how the contribution was distributed among the account's beneficiaries + */ + public AccountContribution(String accountNumber, MonetaryAmount amount, Set distributions) { + this.accountNumber = accountNumber; + this.amount = amount; + this.distributions = distributions; + } + + /** + * Returns the number of the account this contribution was made to. + * @return the account number + */ + public String getAccountNumber() { + return accountNumber; + } + + /** + * Returns the total amount of the contribution. + * @return the contribution amount + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns how this contribution was distributed among the account's beneficiaries. + * @return the contribution distributions + */ + public Set getDistributions() { + return distributions; + } + + /** + * Returns how this contribution was distributed to a single account beneficiary. + * @param beneficiary the name of the beneficiary e.g "Annabelle" + * @return a summary of how the contribution amount was distributed to the beneficiary + */ + public Distribution getDistribution(String beneficiary) { + for (Distribution d : distributions) { + if (d.beneficiary.equals(beneficiary)) { + return d; + } + } + throw new IllegalArgumentException("No such distribution for '" + beneficiary + "'"); + } + + /** + * A single distribution made to a beneficiary as part of an account contribution, summarizing the distribution + * amount and resulting total beneficiary savings. + * + * A value object. + */ + public static class Distribution { + + private String beneficiary; + + private MonetaryAmount amount; + + private Percentage percentage; + + private MonetaryAmount totalSavings; + + /** + * Creates a new distribution. + * @param beneficiary the name of the account beneficiary that received a distribution + * @param amount the distribution amount + * @param percentage this distribution's percentage of the total account contribution + * @param totalSavings the beneficiary's total savings amount after the distribution was made + */ + public Distribution(String beneficiary, MonetaryAmount amount, Percentage percentage, + MonetaryAmount totalSavings) { + this.beneficiary = beneficiary; + this.percentage = percentage; + this.amount = amount; + this.totalSavings = totalSavings; + } + + /** + * Returns the name of the beneficiary. + */ + public String getBeneficiary() { + return beneficiary; + } + + /** + * Returns the amount of this distribution. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the percentage of this distribution relative to others in the contribution. + */ + public Percentage getPercentage() { + return percentage; + } + + /** + * Returns the total savings of the beneficiary after this distribution. + */ + public MonetaryAmount getTotalSavings() { + return totalSavings; + } + + public String toString() { + return amount + " to '" + beneficiary + "' (" + percentage + ")"; + } + } + + public String toString() { + return "Contribution of " + amount + " to account '" + accountNumber + "' distributed " + distributions; + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/Dining.java b/lab/34-spring-data-jpa/src/main/java/rewards/Dining.java new file mode 100644 index 0000000..0df7466 --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/Dining.java @@ -0,0 +1,113 @@ +package rewards; + +import common.datetime.SimpleDate; +import common.money.MonetaryAmount; + +/** + * A dining event that occurred, representing a charge made to a credit card by a merchant on a specific date. + * + * For a dining to be eligible for reward, the credit card number should map to an account in the reward network. In + * addition, the merchant number should map to a restaurant in the network. + * + * A value object. Immutable. + */ +public class Dining { + + private MonetaryAmount amount; + + private String creditCardNumber; + + private String merchantNumber; + + private SimpleDate date; + + /** + * Creates a new dining, reflecting an amount that was charged to a card by a merchant on the date specified. + * @param amount the total amount of the dining bill + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param date the date of the dining event + */ + public Dining(MonetaryAmount amount, String creditCardNumber, String merchantNumber, SimpleDate date) { + this.amount = amount; + this.creditCardNumber = creditCardNumber; + this.merchantNumber = merchantNumber; + this.date = date; + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on today's date. A + * convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, SimpleDate.today()); + } + + /** + * Creates a new dining, reflecting an amount that was charged to a credit card by a merchant on the date specified. + * A convenient static factory method. + * @param amount the total amount of the dining bill as a string + * @param creditCardNumber the number of the credit card used to pay for the dining bill + * @param merchantNumber the merchant number of the restaurant where the dining occurred + * @param month the month of the dining event + * @param day the day of the dining event + * @param year the year of the dining event + * @return the dining event + */ + public static Dining createDining(String amount, String creditCardNumber, String merchantNumber, int month, + int day, int year) { + return new Dining(MonetaryAmount.valueOf(amount), creditCardNumber, merchantNumber, new SimpleDate(month, day, + year)); + } + + /** + * Returns the amount of this dining--the total amount of the bill that was charged to the credit card. + */ + public MonetaryAmount getAmount() { + return amount; + } + + /** + * Returns the number of the credit card used to pay for this dining. For this dining to be eligible for reward, + * this credit card number should be associated with a valid account in the reward network. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Returns the merchant number of the restaurant where this dining occurred. For this dining to be eligible for + * reward, this merchant number should be associated with a valid restaurant in the reward network. + */ + public String getMerchantNumber() { + return merchantNumber; + } + + /** + * Returns the date this dining occurred on. + */ + public SimpleDate getDate() { + return date; + } + + public boolean equals(Object o) { + if (!(o instanceof Dining other)) { + return false; + } + // value objects are equal if their attributes are equal + return amount.equals(other.amount) && creditCardNumber.equals(other.creditCardNumber) + && merchantNumber.equals(other.merchantNumber) && date.equals(other.date); + } + + public int hashCode() { + return amount.hashCode() + creditCardNumber.hashCode() + merchantNumber.hashCode() + date.hashCode(); + } + + public String toString() { + return "Dining of " + amount + " charged to '" + creditCardNumber + "' by '" + merchantNumber + "' on " + date; + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/RewardConfirmation.java b/lab/34-spring-data-jpa/src/main/java/rewards/RewardConfirmation.java new file mode 100644 index 0000000..c6984dc --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/RewardConfirmation.java @@ -0,0 +1,41 @@ +package rewards; + +/** + * A summary of a confirmed reward transaction describing a contribution made to an account that was distributed among + * the account's beneficiaries. + */ +public class RewardConfirmation { + + private String confirmationNumber; + + private AccountContribution accountContribution; + + /** + * Creates a new reward confirmation. + * @param confirmationNumber the unique confirmation number + * @param accountContribution a summary of the account contribution that was made + */ + public RewardConfirmation(String confirmationNumber, AccountContribution accountContribution) { + this.confirmationNumber = confirmationNumber; + this.accountContribution = accountContribution; + } + + /** + * Returns the confirmation number of the reward transaction. Can be used later to lookup the transaction record. + */ + public String getConfirmationNumber() { + return confirmationNumber; + } + + /** + * Returns a summary of the monetary contribution that was made to an account. + * @return the account contribution (the details of this reward) + */ + public AccountContribution getAccountContribution() { + return accountContribution; + } + + public String toString() { + return confirmationNumber; + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/RewardNetwork.java b/lab/34-spring-data-jpa/src/main/java/rewards/RewardNetwork.java new file mode 100644 index 0000000..f17157b --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/RewardNetwork.java @@ -0,0 +1,28 @@ +package rewards; + +/** + * Rewards a member account for dining at a restaurant. + * + * A reward takes the form of a monetary contribution made to an account that is distributed among the account's + * beneficiaries. The contribution amount is typically a function of several factors such as the dining amount and + * restaurant where the dining occurred. + * + * Example: Papa Keith spends $100.00 at Apple Bee's resulting in a $8.00 contribution to his account that is + * distributed evenly among his beneficiaries Annabelle and Corgan. + * + * This is the central application-boundary for the "rewards" application. This is the public interface users call to + * invoke the application. This is the entry-point into the Application Layer. + */ +public interface RewardNetwork { + + /** + * Reward an account for dining. + * + * For a dining to be eligible for reward: - It must have been paid for by a registered credit card of a valid + * member account in the network. - It must have taken place at a restaurant participating in the network. + * + * @param dining a charge made to a credit card for dining at a restaurant + * @return confirmation of the reward + */ + RewardConfirmation rewardAccountFor(Dining dining); +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/RewardsApplication.java b/lab/34-spring-data-jpa/src/main/java/rewards/RewardsApplication.java new file mode 100644 index 0000000..28c624f --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/RewardsApplication.java @@ -0,0 +1,51 @@ +package rewards; + +import config.RewardsConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +// TODO-00: In this lab, you are going to exercise the following: +// - Writing Spring Data repository interfaces +// - Annotating a JPA Entity class +// - Setting some JPA related properties + +// TODO-01: Review dependency as described in the TO-DO-01 +// in the pom.xml or build.gradle + +@SpringBootApplication +@Import(RewardsConfig.class) +public class RewardsApplication { + private final Logger logger = LoggerFactory.getLogger(RewardsApplication.class); + + private static final String SQL = "SELECT count(*) FROM T_ACCOUNT"; + + public static void main(String[] args) { + SpringApplication.run(RewardsApplication.class, args); + } + + @Component + public final class QueryAccountCountRunner implements CommandLineRunner { + + private final JdbcTemplate jdbcTemplate; + + public QueryAccountCountRunner(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public void run(String... args) { + long accountCount = this.jdbcTemplate.queryForObject(SQL, Long.class); + logger.info("Number of accounts:{}", accountCount); + } + } +} + +// TODO-07: Configure JPA as specified in the TO-DO-07 in the +// src/test/resources/application.properties +// ("application.properties" file used for testing) diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/RewardNetworkImpl.java b/lab/34-spring-data-jpa/src/main/java/rewards/internal/RewardNetworkImpl.java new file mode 100644 index 0000000..1379226 --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/RewardNetworkImpl.java @@ -0,0 +1,53 @@ +package rewards.internal; + +import org.springframework.transaction.annotation.Transactional; + +import common.money.MonetaryAmount; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.RewardNetwork; +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +/** + * Rewards an Account for Dining at a Restaurant. + * + * The sole Reward Network implementation. This object is an application-layer service responsible for coordinating with + * the domain-layer to carry out the process of rewarding benefits to accounts for dining. + * + * Said in other words, this class implements the "reward account for dining" use case. + */ +public class RewardNetworkImpl implements RewardNetwork { + + private final AccountRepository accountRepository; + + private final RestaurantRepository restaurantRepository; + + private final RewardRepository rewardRepository; + + /** + * Creates a new reward network. + * @param accountRepository the repository for loading accounts to reward + * @param restaurantRepository the repository for loading restaurants that determine how much to reward + * @param rewardRepository the repository for recording a record of successful reward transactions + */ + public RewardNetworkImpl(AccountRepository accountRepository, RestaurantRepository restaurantRepository, + RewardRepository rewardRepository) { + this.accountRepository = accountRepository; + this.restaurantRepository = restaurantRepository; + this.rewardRepository = rewardRepository; + } + + @Transactional + public RewardConfirmation rewardAccountFor(Dining dining) { + Account account = accountRepository.findByCreditCard(dining.getCreditCardNumber()); + Restaurant restaurant = restaurantRepository.findByMerchantNumber(dining.getMerchantNumber()); + MonetaryAmount amount = restaurant.calculateBenefitFor(account, dining); + AccountContribution contribution = account.makeContribution(amount); + return rewardRepository.confirmReward(contribution, dining); + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/account/Account.java b/lab/34-spring-data-jpa/src/main/java/rewards/internal/account/Account.java new file mode 100644 index 0000000..e692baa --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/account/Account.java @@ -0,0 +1,222 @@ +package rewards.internal.account; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import rewards.AccountContribution; +import rewards.AccountContribution.Distribution; + +import jakarta.persistence.*; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * An account for a member of the reward network. An account has one or more + * beneficiaries whose allocations must add up to 100%. + * + * An account can make contributions to its beneficiaries. Each contribution is + * distributed among the beneficiaries based on an allocation. + * + * An entity. An aggregate. + */ +// TODO-02: Review the JPA annotations on this class and make sure you know what each does. +// @Entity - Marks this class as a JPA persistent class +// @Table - Specifies the exact table name to use on the DB (would be "Account" if unspecified). +// @Id - Indicates the field to use as the primary key on the database +// @Column - Identifies column-level customization, such as the exact name of the column on the table. +// @OneToMany - Identifies the field on the 'one' side of a one to many relationship. +// @JoinColumn - Identifies the column on the 'many' table containing the column to be used when joining. Usually a foreign key. +@Entity +@Table(name = "T_ACCOUNT") +public class Account { + + @Id + @Column(name = "ID") + private Long entityId; + + // No need for @Column, mapped automatically to NUMBER + private String number; + + // No need for @Column, mapped automatically to NAME + private String name; + + @OneToMany + @JoinColumn(name = "ACCOUNT_ID") + private Set beneficiaries = new HashSet<>(); + + @Column(name = "CREDIT_CARD") + private String creditCardNumber; + + public Account() { + } + + /** + * Create a new account. + * + * @param number the account number + * @param name the name on the account + */ + public Account(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Getter for the credit card number for this account. + * + * @return the credit card number for this account as a 16-character String. + */ + public String getCreditCardNumber() { + return creditCardNumber; + } + + /** + * Setter for the credit card number for this account. + * + * @param creditCardNumber + */ + public void setCreditCardNumber(String creditCardNumber) { + this.creditCardNumber = creditCardNumber; + } + + /** + * Returns the number used to uniquely identify this account. + */ + public String getNumber() { + return number; + } + + /** + * Returns the name on file for this account. + */ + public String getName() { + return name; + } + + /** + * Returns the id for this account. + */ + public Long getEntityId() { + return entityId; + } + + /** + * Sets the id for this account. Package local - only available to tests. + */ + void setEntityId(Long entityId) { + this.entityId = entityId; + } + + /** + * Add a single beneficiary with a 100% allocation percentage. + * + * @param beneficiaryName the name of the beneficiary (should be unique) + */ + public void addBeneficiary(String beneficiaryName) { + addBeneficiary(beneficiaryName, Percentage.oneHundred()); + } + + /** + * Add a single beneficiary with the specified allocation percentage. + * + * @param beneficiaryName the name of the beneficiary (should be unique) + * @param allocationPercentage the beneficiary's allocation percentage within + * this account + */ + public void addBeneficiary(String beneficiaryName, Percentage allocationPercentage) { + beneficiaries.add(new Beneficiary(beneficiaryName, allocationPercentage)); + } + + /** + * Validation check that returns true only if the total beneficiary allocation + * adds up to 100%. + */ + public boolean isValid() { + Percentage totalPercentage = Percentage.zero(); + for (Beneficiary b : beneficiaries) { + try { + totalPercentage = totalPercentage.add(b.getAllocationPercentage()); + } catch (IllegalArgumentException e) { + // total would have been over 100% - return invalid + return false; + } + } + return totalPercentage.equals(Percentage.oneHundred()); + } + + /** + * Make a monetary contribution to this account. The contribution amount is + * distributed among the account's beneficiaries based on each beneficiary's + * allocation percentage. + * + * @param amount the total amount to contribute + */ + public AccountContribution makeContribution(MonetaryAmount amount) { + if (!isValid()) { + throw new IllegalStateException( + "Cannot make contributions to this account: it has invalid beneficiary allocations"); + } + Set distributions = distribute(amount); + return new AccountContribution(getNumber(), amount, distributions); + } + + /** + * Distribute the contribution amount among this account's beneficiaries. + * + * @param amount the total contribution amount + * @return the individual beneficiary distributions + */ + private Set distribute(MonetaryAmount amount) { + Set distributions = new HashSet<>(beneficiaries.size()); + for (Beneficiary beneficiary : beneficiaries) { + MonetaryAmount distributionAmount = amount.multiplyBy(beneficiary.getAllocationPercentage()); + beneficiary.credit(distributionAmount); + Distribution distribution = new Distribution(beneficiary.getName(), distributionAmount, + beneficiary.getAllocationPercentage(), beneficiary.getSavings()); + distributions.add(distribution); + } + return distributions; + } + + /** + * Returns the beneficiaries for this account. Callers should not attempt to + * hold on or modify the returned set. This method should only be used + * transitively; for example, called to facilitate account reporting. + * + * @return the beneficiaries of this account + */ + public Set getBeneficiaries() { + return Collections.unmodifiableSet(beneficiaries); + } + + /** + * Returns a single account beneficiary. Callers should not attempt to hold on + * or modify the returned object. This method should only be used transitively; + * for example, called to facilitate reporting or testing. + * + * @param name the name of the beneficiary e.g "Annabelle" + * @return the beneficiary object + */ + public Beneficiary getBeneficiary(String name) { + for (Beneficiary b : beneficiaries) { + if (b.getName().equals(name)) { + return b; + } + } + throw new IllegalArgumentException("No such beneficiary with name '" + name + "'"); + } + + /** + * Used to restore an allocated beneficiary. Should only be called by the + * repository responsible for reconstituting this account. + * + * @param beneficiary the beneficiary + */ + void restoreBeneficiary(Beneficiary beneficiary) { + beneficiaries.add(beneficiary); + } + + public String toString() { + return "Number = '" + number + "', name = " + name + "', beneficiaries = " + beneficiaries; + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/account/AccountRepository.java b/lab/34-spring-data-jpa/src/main/java/rewards/internal/account/AccountRepository.java new file mode 100644 index 0000000..00d5022 --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/account/AccountRepository.java @@ -0,0 +1,26 @@ +package rewards.internal.account; + +/** + * Loads account aggregates. Called by the reward network to find and + * reconstitute Account entities from an external form such as a set of RDMS + * rows. + * + * Objects returned by this repository are guaranteed to be fully initialized + * and ready to use. + */ +// TODO-03: Alter this interface to extend a proper Spring Data interface. +// - The finder method on this class must be changed to obey Spring Data +// conventions - use refactoring feature of the IDE +public interface AccountRepository { + + /** + * Load an account by its credit card. + * + * @param creditCardNumber + * the credit card number + * @return the account object + */ + // To refactor: right click on the method name -> Refactor -> Rename + Account findByCreditCard(String creditCardNumber); + +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/account/Beneficiary.java b/lab/34-spring-data-jpa/src/main/java/rewards/internal/account/Beneficiary.java new file mode 100644 index 0000000..474a62d --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/account/Beneficiary.java @@ -0,0 +1,101 @@ +package rewards.internal.account; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * A single beneficiary allocated to an account. Each beneficiary has a name (e.g. Annabelle) and a savings balance + * tracking how much money has been saved for he or she to date (e.g. $1000). + */ +// TODO-04: Review the JPA annotations on this class and make sure you know what each does. +// @AttributeOverride - Tells JPA to use the ALLOCATION_PERCENTAGE column on T_ACCOUNT_BENEFICIARY +// to populate Percentage.value. +@Entity +@Table(name="T_ACCOUNT_BENEFICIARY") +public class Beneficiary { + @Id + @Column(name="ID") + private Long entityId; + + // No need for @Column, mapped automatically to NAME + private String name; + + @AttributeOverride(name="value",column=@Column(name="ALLOCATION_PERCENTAGE")) + private Percentage allocationPercentage; + + @AttributeOverride(name="value",column=@Column(name="SAVINGS")) + private MonetaryAmount savings = MonetaryAmount.zero(); + + public Beneficiary() { + } + + /** + * Creates a new account beneficiary. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + */ + public Beneficiary(String name, Percentage allocationPercentage) { + this.name = name; + this.allocationPercentage = allocationPercentage; + } + + /** + * Creates a new account beneficiary. This constructor should be called by privileged objects responsible for + * reconstituting an existing Account object from some external form such as a collection of database records. + * Marked package-private to indicate this constructor should never be called by general application code. + * @param name the name of the beneficiary + * @param allocationPercentage the beneficiary's allocation percentage within its account + * @param savings the total amount saved to-date for this beneficiary + */ + Beneficiary(String name, Percentage allocationPercentage, MonetaryAmount savings) { + this.name = name; + this.allocationPercentage = allocationPercentage; + this.savings = savings; + } + + /** + * Returns the beneficiary name. + */ + public String getName() { + return name; + } + + /** + * Returns the beneficiary's allocation percentage in this account. + */ + public Percentage getAllocationPercentage() { + return allocationPercentage; + } + + /** + * Returns the amount of savings this beneficiary has accrued. + */ + public MonetaryAmount getSavings() { + return savings; + } + + /** + * Returns the id for this beneficiary. + */ + public Long getEntityId() { + return entityId; + } + + /** + * Credit the amount to this beneficiary's saving balance. + * @param amount the amount to credit + */ + public void credit(MonetaryAmount amount) { + savings = savings.add(amount); + } + + public String toString() { + return "name = '" + name + "', allocationPercentage = " + allocationPercentage + ", savings = " + savings + ")"; + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/account/package.html b/lab/34-spring-data-jpa/src/main/java/rewards/internal/account/package.html new file mode 100644 index 0000000..9c20aa3 --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/account/package.html @@ -0,0 +1,7 @@ + + +

+The Account module. +

+ + diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/package.html b/lab/34-spring-data-jpa/src/main/java/rewards/internal/package.html new file mode 100644 index 0000000..8d14d1b --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/package.html @@ -0,0 +1,7 @@ + + +

+The implementation of the rewards application. +

+ + diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/AlwaysAvailable.java b/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/AlwaysAvailable.java new file mode 100644 index 0000000..f97fbeb --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/AlwaysAvailable.java @@ -0,0 +1,19 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * A benefit availabilty policy that returns true at all times. + */ +public class AlwaysAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new AlwaysAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + + public String toString() { + return "alwaysAvailable"; + } +} diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java b/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java new file mode 100644 index 0000000..18e2806 --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/BenefitAvailabilityPolicy.java @@ -0,0 +1,20 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * Determines if benefit is available for an account for dining. + * + * A value object. A strategy. Scoped by the Restaurant aggregate. + */ +public interface BenefitAvailabilityPolicy { + + /** + * Calculates if an account is eligible to receive benefits for a dining. + * @param account the account of the member who dined + * @param dining the dining event + * @return benefit availability status + */ + boolean isBenefitAvailableFor(Account account, Dining dining); +} diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/NeverAvailable.java b/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/NeverAvailable.java new file mode 100644 index 0000000..8f6cbc3 --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/NeverAvailable.java @@ -0,0 +1,19 @@ +package rewards.internal.restaurant; + +import rewards.Dining; +import rewards.internal.account.Account; + +/** + * A benefit availabilty policy that returns false at all times. + */ +public class NeverAvailable implements BenefitAvailabilityPolicy { + static final BenefitAvailabilityPolicy INSTANCE = new NeverAvailable(); + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return false; + } + + public String toString() { + return "neverAvailable"; + } +} diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/Restaurant.java b/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/Restaurant.java new file mode 100644 index 0000000..7a6d687 --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/Restaurant.java @@ -0,0 +1,172 @@ +package rewards.internal.restaurant; + +import common.money.MonetaryAmount; +import common.money.Percentage; +import rewards.Dining; +import rewards.internal.account.Account; + +import jakarta.persistence.Transient; + +/** + * Restaurants calculate how much benefit may be awarded to an account for + * dining based on an availability policy and a benefit percentage. + */ +// TODO-05: Map this class using JPA Annotations. +// - Use the following SQL statement in the schema.sql as a guidance. +// +// create table T_RESTAURANT (ID integer identity primary key, +// MERCHANT_NUMBER varchar(10) not null, +// NAME varchar(80) not null, +// BENEFIT_PERCENTAGE decimal(5,2) not null, +// BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +public class Restaurant { + + private Long entityId; + + private String number; + + private String name; + + // This is not a simple mapping as Percentage is not a simple type. + // You need to map Percentage.value from a column in T_RESTAURANT. If unsure, + // look at how Beneficiary does it. + private Percentage benefitPercentage; + + + // DO NOT map this field. For now it is always set to AlwaysAvailable. + // The bonus section later will redo this mapping. + @Transient + private BenefitAvailabilityPolicy benefitAvailabilityPolicy = AlwaysAvailable.INSTANCE; + + public Restaurant() { + //Needed by the JPA spec + } + + /** + * Creates a new restaurant. + * + * @param number + * the restaurant's merchant number + * @param name + * the name of the restaurant + */ + public Restaurant(String number, String name) { + this.number = number; + this.name = name; + } + + /** + * Sets the percentage benefit to be awarded for eligible dining + * transactions. + * + * @param benefitPercentage + * the benefit percentage + */ + public void setBenefitPercentage(Percentage benefitPercentage) { + this.benefitPercentage = benefitPercentage; + } + + /** + * Sets the policy that determines if a dining by an account at this + * restaurant is eligible for benefit. + * + * @param benefitAvailabilityPolicy + * the benefit availability policy + */ + public void setBenefitAvailabilityPolicy( + BenefitAvailabilityPolicy benefitAvailabilityPolicy) { + this.benefitAvailabilityPolicy = benefitAvailabilityPolicy; + } + + /** + * Returns the name of this restaurant. + */ + public String getName() { + return name; + } + + /** + * Returns the merchant number of this restaurant. + */ + public String getNumber() { + return number; + } + + /** + * Returns this restaurant's benefit percentage. + */ + public Percentage getBenefitPercentage() { + return benefitPercentage; + } + + /** + * Returns this restaurant's benefit availability policy. + */ + public BenefitAvailabilityPolicy getBenefitAvailabilityPolicy() { + return benefitAvailabilityPolicy; + } + + /** + * Returns the id for this restaurant. + */ + public Long getEntityId() { + return entityId; + } + + /** + * Calculate the benefit eligible to this account for dining at this + * restaurant. + * + * @param account + * the account that dined at this restaurant + * @param dining + * a dining event that occurred + * @return the benefit amount eligible for reward + */ + public MonetaryAmount calculateBenefitFor(Account account, Dining dining) { + if (benefitAvailabilityPolicy.isBenefitAvailableFor(account, dining)) { + return dining.getAmount().multiplyBy(benefitPercentage); + } else { + return MonetaryAmount.zero(); + } + } + + public String toString() { + return "Number = '" + number + "', name = '" + name + + "', benefitPercentage = " + benefitPercentage + + ", benefitAvailabilityPolicy = " + benefitAvailabilityPolicy; + } + + // Internal methods for JPA only - hence they are protected. + /** + * Sets this restaurant's benefit availability policy from the code stored + * in the underlying column. This is a database specific accessor using the + * JPA 2 @Access annotation. + */ + protected void setDbBenefitAvailabilityPolicy(String policyCode) { + if ("A".equals(policyCode)) { + benefitAvailabilityPolicy = AlwaysAvailable.INSTANCE; + } else if ("N".equals(policyCode)) { + benefitAvailabilityPolicy = NeverAvailable.INSTANCE; + } else { + throw new IllegalArgumentException("Not a supported policy code " + + policyCode); + } + } + + /** + * Returns this restaurant's benefit availability policy code for storage in + * the underlying column. This is a database specific accessor using the JPA + * 2 @Access annotation. + */ + protected String getDbBenefitAvailabilityPolicy() { + if (benefitAvailabilityPolicy == AlwaysAvailable.INSTANCE) { + return "A"; + } else if (benefitAvailabilityPolicy == NeverAvailable.INSTANCE) { + return "N"; + } else { + throw new IllegalArgumentException("No policy code for " + + benefitAvailabilityPolicy.getClass()); + } + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/RestaurantRepository.java b/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/RestaurantRepository.java new file mode 100644 index 0000000..1cf7177 --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/RestaurantRepository.java @@ -0,0 +1,21 @@ +package rewards.internal.restaurant; + +/** + * Loads restaurant aggregates. Called by the reward network to find and reconstitute Restaurant entities from an + * external form such as a set of RDMS rows. + * + * Objects returned by this repository are guaranteed to be fully-initialized and ready to use. + */ +// TODO-06: Alter this interface to extend a proper Spring Data interface. +// - The method name also needs refactoring (renaming) to use Spring Data finder +// naming conventions so Spring Data will implement it automatically for you. +public interface RestaurantRepository { + + /** + * Load a Restaurant entity by its merchant number. + * @param merchantNumber the merchant number + * @return the restaurant + */ + // To refactor: right click on the method name -> Refactor -> Rename + Restaurant findByMerchantNumber(String merchantNumber); +} diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/package.html b/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/package.html new file mode 100644 index 0000000..96aff8d --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/restaurant/package.html @@ -0,0 +1,7 @@ + + +

+The Restaurant module. +

+ + diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/reward/JdbcRewardRepository.java b/lab/34-spring-data-jpa/src/main/java/rewards/internal/reward/JdbcRewardRepository.java new file mode 100644 index 0000000..e5ae22b --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/reward/JdbcRewardRepository.java @@ -0,0 +1,38 @@ +package rewards.internal.reward; + +import javax.sql.DataSource; + +import org.springframework.jdbc.core.JdbcTemplate; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +import common.datetime.SimpleDate; + +/** + * JDBC implementation of a reward repository that records the result of a reward transaction by inserting a reward + * confirmation record. + */ +public class JdbcRewardRepository implements RewardRepository { + + private JdbcTemplate jdbcTemplate; + + public void setDataSource(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + String sql = "insert into T_REWARD (CONFIRMATION_NUMBER, REWARD_AMOUNT, REWARD_DATE, ACCOUNT_NUMBER, DINING_MERCHANT_NUMBER, DINING_DATE, DINING_AMOUNT) values (?, ?, ?, ?, ?, ?, ?)"; + String confirmationNumber = nextConfirmationNumber(); + jdbcTemplate.update(sql, confirmationNumber, contribution.getAmount().asBigDecimal(), + SimpleDate.today().asDate(), contribution.getAccountNumber(), dining.getMerchantNumber(), + dining.getDate().asDate(), dining.getAmount().asBigDecimal()); + return new RewardConfirmation(confirmationNumber, contribution); + } + + private String nextConfirmationNumber() { + String sql = "select next value for S_REWARD_CONFIRMATION_NUMBER from DUAL_REWARD_CONFIRMATION_NUMBER"; + return jdbcTemplate.queryForObject(sql, String.class); + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/reward/RewardRepository.java b/lab/34-spring-data-jpa/src/main/java/rewards/internal/reward/RewardRepository.java new file mode 100644 index 0000000..1207f0f --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/reward/RewardRepository.java @@ -0,0 +1,20 @@ +package rewards.internal.reward; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; + +/** + * Handles creating records of reward transactions to track contributions made to accounts for dining at restaurants. + */ +public interface RewardRepository { + + /** + * Create a record of a reward that will track a contribution made to an account for dining. + * @param contribution the account contribution that was made + * @param dining the dining event that resulted in the account contribution + * @return a reward confirmation object that can be used for reporting and to lookup the reward details at a later + * date + */ + RewardConfirmation confirmReward(AccountContribution contribution, Dining dining); +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/internal/reward/package.html b/lab/34-spring-data-jpa/src/main/java/rewards/internal/reward/package.html new file mode 100644 index 0000000..80e1b31 --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/internal/reward/package.html @@ -0,0 +1,7 @@ + + +

+The Reward module. +

+ + diff --git a/lab/34-spring-data-jpa/src/main/java/rewards/package.html b/lab/34-spring-data-jpa/src/main/java/rewards/package.html new file mode 100644 index 0000000..1441397 --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/java/rewards/package.html @@ -0,0 +1,7 @@ + + +

+The public interface of the rewards application defined by the central RewardNetwork. +

+ + diff --git a/lab/34-spring-data-jpa/src/main/resources/application.properties b/lab/34-spring-data-jpa/src/main/resources/application.properties new file mode 100644 index 0000000..3ae7a55 --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/resources/application.properties @@ -0,0 +1,4 @@ +logging.level.root=INFO + +# Don't need Hibernate to populate the database, our scripts did it already +spring.jpa.hibernate.ddl-auto=none \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/main/resources/data.sql b/lab/34-spring-data-jpa/src/main/resources/data.sql new file mode 100644 index 0000000..cf0750b --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/resources/data.sql @@ -0,0 +1,77 @@ +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) +values (17, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) +values ('1234567890', 'AppleBees', .08, 'A'); diff --git a/lab/34-spring-data-jpa/src/main/resources/schema.sql b/lab/34-spring-data-jpa/src/main/resources/schema.sql new file mode 100644 index 0000000..cc253d5 --- /dev/null +++ b/lab/34-spring-data-jpa/src/main/resources/schema.sql @@ -0,0 +1,20 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, unique(NUMBER)); +create table T_ACCOUNT_CREDIT_CARD (ID integer identity primary key, ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(3,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_CREDIT_CARD add constraint FK_ACCOUNT_CREDIT_CARD foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; diff --git a/lab/34-spring-data-jpa/src/test/java/rewards/RewardNetworkTests.java b/lab/34-spring-data-jpa/src/test/java/rewards/RewardNetworkTests.java new file mode 100644 index 0000000..c4cfc3d --- /dev/null +++ b/lab/34-spring-data-jpa/src/test/java/rewards/RewardNetworkTests.java @@ -0,0 +1,58 @@ +package rewards; + +import common.money.MonetaryAmount; +import config.RewardsConfig; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A system test that verifies the components of the RewardNetwork application work together to reward for dining + * successfully. Uses Spring to bootstrap the application for use in a test environment. + */ + +@SpringBootTest +public class RewardNetworkTests { + + /** + * The object being tested. + */ + @Autowired + private RewardNetwork rewardNetwork; + + // TODO-08 Run this test (if running with Gradle, make sure to remove the test exclusion in the build.gradle file. + // It should pass. + @Test + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the contribution account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/test/java/rewards/internal/RewardNetworkImplTests.java b/lab/34-spring-data-jpa/src/test/java/rewards/internal/RewardNetworkImplTests.java new file mode 100644 index 0000000..c39ae30 --- /dev/null +++ b/lab/34-spring-data-jpa/src/test/java/rewards/internal/RewardNetworkImplTests.java @@ -0,0 +1,73 @@ +package rewards.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import common.money.MonetaryAmount; +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.AccountRepository; +import rewards.internal.restaurant.RestaurantRepository; +import rewards.internal.reward.RewardRepository; + +/** + * Unit tests for the RewardNetworkImpl application logic. Configures the implementation with stub repositories + * containing dummy data for fast in-memory testing without the overhead of an external data source. + * + * Besides helping catch bugs early, tests are a great way for a new developer to learn an API as he or she can see the + * API in action. Tests also help validate a design as they are a measure for how easy it is to use your code. + */ +public class RewardNetworkImplTests { + + /** + * The object being tested. + */ + private RewardNetworkImpl rewardNetwork; + + @BeforeEach + public void setUp() { + // create stubs to facilitate fast in-memory testing with dummy data and no external dependencies + AccountRepository accountRepo = new StubAccountRepository(); + RestaurantRepository restaurantRepo = new StubRestaurantRepository(); + RewardRepository rewardRepo = new StubRewardRepository(); + + // setup the object being tested by handing what it needs to work + rewardNetwork = new RewardNetworkImpl(accountRepo, restaurantRepo, rewardRepo); + } + + @Test + @Disabled + public void testRewardForDining() { + // create a new dining of 100.00 charged to credit card '1234123412341234' by merchant '123457890' as test input + Dining dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + + // call the 'rewardNetwork' to test its rewardAccountFor(Dining) method + RewardConfirmation confirmation = rewardNetwork.rewardAccountFor(dining); + + // assert the expected reward confirmation results + assertNotNull(confirmation); + assertNotNull(confirmation.getConfirmationNumber()); + + // assert an account contribution was made + AccountContribution contribution = confirmation.getAccountContribution(); + assertNotNull(contribution); + + // the account number should be '123456789' + assertEquals("123456789", contribution.getAccountNumber()); + + // the total contribution amount should be 8.00 (8% of 100.00) + assertEquals(MonetaryAmount.valueOf("8.00"), contribution.getAmount()); + + // the total contribution amount should have been split into 2 distributions + assertEquals(2, contribution.getDistributions().size()); + + // each distribution should be 4.00 (as both have a 50% allocation) + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("4.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/test/java/rewards/internal/StubAccountRepository.java b/lab/34-spring-data-jpa/src/test/java/rewards/internal/StubAccountRepository.java new file mode 100644 index 0000000..fc8e35b --- /dev/null +++ b/lab/34-spring-data-jpa/src/test/java/rewards/internal/StubAccountRepository.java @@ -0,0 +1,43 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.orm.ObjectRetrievalFailureException; + +import rewards.internal.account.Account; +import rewards.internal.account.AccountRepository; + +import common.money.Percentage; + +/** + * A dummy account repository implementation. Has a single Account "Keith and Keri Donald" with two beneficiaries + * "Annabelle" (50% allocation) and "Corgan" (50% allocation) associated with credit card "1234123412341234". + * + * Stubs facilitate unit testing. An object needing an AccountRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubAccountRepository implements AccountRepository { + + private final Map accountsByCreditCard = new HashMap<>(); + + public StubAccountRepository() { + Account account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + accountsByCreditCard.put("1234123412341234", account); + } + + public Account findByCreditCard(String creditCardNumber) { + Account account = accountsByCreditCard.get(creditCardNumber); + if (account == null) { + throw new ObjectRetrievalFailureException(Account.class, creditCardNumber); + } + return account; + } + + public void updateBeneficiaries(Account account) { + // nothing to do, everything is in memory + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/test/java/rewards/internal/StubRestaurantRepository.java b/lab/34-spring-data-jpa/src/test/java/rewards/internal/StubRestaurantRepository.java new file mode 100644 index 0000000..063ba61 --- /dev/null +++ b/lab/34-spring-data-jpa/src/test/java/rewards/internal/StubRestaurantRepository.java @@ -0,0 +1,53 @@ +package rewards.internal; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.orm.ObjectRetrievalFailureException; + +import rewards.Dining; +import rewards.internal.account.Account; +import rewards.internal.restaurant.BenefitAvailabilityPolicy; +import rewards.internal.restaurant.Restaurant; +import rewards.internal.restaurant.RestaurantRepository; + +import common.money.Percentage; + +/** + * A dummy restaurant repository implementation. Has a single restaurant "Apple Bees" with a 8% benefit availability + * percentage that's always available. + * + * Stubs facilitate unit testing. An object needing a RestaurantRepository can work with this stub and not have to bring + * in expensive and/or complex dependencies such as a Database. Simple unit tests can then verify object behavior by + * considering the state of this stub. + */ +public class StubRestaurantRepository implements RestaurantRepository { + + private final Map restaurantsByMerchantNumber = new HashMap<>(); + + public StubRestaurantRepository() { + Restaurant restaurant = new Restaurant("1234567890", "Apple Bees"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new AlwaysReturnsTrue()); + restaurantsByMerchantNumber.put(restaurant.getNumber(), restaurant); + } + + public Restaurant findByMerchantNumber(String merchantNumber) { + Restaurant restaurant = (Restaurant) restaurantsByMerchantNumber.get(merchantNumber); + if (restaurant == null) { + throw new ObjectRetrievalFailureException(Restaurant.class, merchantNumber); + } + return restaurant; + } + + /** + * A simple "dummy" benefit availability policy that always returns true. Only useful for testing--a real + * availability policy might consider many factors such as the day of week of the dining, or the account's reward + * history for the current month. + */ + private static class AlwaysReturnsTrue implements BenefitAvailabilityPolicy { + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return true; + } + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/test/java/rewards/internal/StubRewardRepository.java b/lab/34-spring-data-jpa/src/test/java/rewards/internal/StubRewardRepository.java new file mode 100644 index 0000000..2487aca --- /dev/null +++ b/lab/34-spring-data-jpa/src/test/java/rewards/internal/StubRewardRepository.java @@ -0,0 +1,22 @@ +package rewards.internal; + +import java.util.Random; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.reward.RewardRepository; + +/** + * A dummy reward repository implementation. + */ +public class StubRewardRepository implements RewardRepository { + + public RewardConfirmation confirmReward(AccountContribution contribution, Dining dining) { + return new RewardConfirmation(confirmationNumber(), contribution); + } + + private String confirmationNumber() { + return new Random().toString(); + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/test/java/rewards/internal/account/AccountTests.java b/lab/34-spring-data-jpa/src/test/java/rewards/internal/account/AccountTests.java new file mode 100644 index 0000000..4075654 --- /dev/null +++ b/lab/34-spring-data-jpa/src/test/java/rewards/internal/account/AccountTests.java @@ -0,0 +1,57 @@ +package rewards.internal.account; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for the Account class that verify Account behavior works in isolation. + */ +public class AccountTests { + + private final Account account = new Account("1", "Keith and Keri Donald"); + + @Test + public void accountIsValid() { + // setup account with a valid set of beneficiaries to prepare for testing + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + assertTrue(account.isValid()); + } + + @Test + public void accountIsInvalidWithNoBeneficiaries() { + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreOver100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("100%")); + assertFalse(account.isValid()); + } + + @Test + public void accountIsInvalidWhenBeneficiaryAllocationsAreUnder100() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("25%")); + assertFalse(account.isValid()); + } + + @Test + public void makeContribution() { + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("100.00")); + assertEquals(contribution.getAmount(), MonetaryAmount.valueOf("100.00")); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Annabelle").getAmount()); + assertEquals(MonetaryAmount.valueOf("50.00"), contribution.getDistribution("Corgan").getAmount()); + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/test/java/rewards/internal/restaurant/RestaurantTests.java b/lab/34-spring-data-jpa/src/test/java/rewards/internal/restaurant/RestaurantTests.java new file mode 100644 index 0000000..fcc58c2 --- /dev/null +++ b/lab/34-spring-data-jpa/src/test/java/rewards/internal/restaurant/RestaurantTests.java @@ -0,0 +1,71 @@ +package rewards.internal.restaurant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.Dining; +import rewards.internal.account.Account; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Unit tests for exercising the behavior of the Restaurant aggregate entity. A restaurant calculates a benefit to award + * to an account for dining based on an availability policy and benefit percentage. + */ +public class RestaurantTests { + + private Restaurant restaurant; + + private Account account; + + private Dining dining; + + @BeforeEach + public void setUp() { + // configure the restaurant, the object being tested + restaurant = new Restaurant("1234567890", "AppleBee's"); + restaurant.setBenefitPercentage(Percentage.valueOf("8%")); + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(true)); + // configure supporting objects needed by the restaurant + account = new Account("123456789", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle"); + dining = Dining.createDining("100.00", "1234123412341234", "1234567890"); + } + + @Test + public void testCalcuateBenefitFor() { + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert 8.00 eligible for reward + assertEquals(MonetaryAmount.valueOf("8.00"), benefit); + } + + @Test + public void testNoBenefitAvailable() { + // configure stub that always returns false + restaurant.setBenefitAvailabilityPolicy(new StubBenefitAvailibilityPolicy(false)); + MonetaryAmount benefit = restaurant.calculateBenefitFor(account, dining); + // assert zero eligible for reward + assertEquals(MonetaryAmount.valueOf("0.00"), benefit); + } + + /** + * A simple "dummy" benefit availability policy containing a single flag used to determine if benefit is available. + * Only useful for testing - a real availability policy might consider many factors such as the day of week of the + * dining, or the account's reward history for the current month. + */ + private static class StubBenefitAvailibilityPolicy implements BenefitAvailabilityPolicy { + + private final boolean isBenefitAvailable; + + public StubBenefitAvailibilityPolicy(boolean isBenefitAvailable) { + this.isBenefitAvailable = isBenefitAvailable; + } + + public boolean isBenefitAvailableFor(Account account, Dining dining) { + return isBenefitAvailable; + } + } +} \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java b/lab/34-spring-data-jpa/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java new file mode 100644 index 0000000..a5dd389 --- /dev/null +++ b/lab/34-spring-data-jpa/src/test/java/rewards/internal/reward/JdbcRewardRepositoryTests.java @@ -0,0 +1,80 @@ +package rewards.internal.reward; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import rewards.AccountContribution; +import rewards.Dining; +import rewards.RewardConfirmation; +import rewards.internal.account.Account; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; + +import common.money.MonetaryAmount; +import common.money.Percentage; + +/** + * Tests the JDBC reward repository with a test data source to verify data access and relational-to-object mapping + * behavior works as expected. + */ +public class JdbcRewardRepositoryTests { + + private JdbcRewardRepository repository; + + private DataSource dataSource; + + @BeforeEach + public void setUp() { + repository = new JdbcRewardRepository(); + dataSource = createTestDataSource(); + repository.setDataSource(dataSource); + } + + @Test + public void testCreateReward() throws SQLException { + Dining dining = Dining.createDining("100.00", "1234123412341234", "0123456789"); + + Account account = new Account("1", "Keith and Keri Donald"); + account.addBeneficiary("Annabelle", Percentage.valueOf("50%")); + account.addBeneficiary("Corgan", Percentage.valueOf("50%")); + + AccountContribution contribution = account.makeContribution(MonetaryAmount.valueOf("8.00")); + RewardConfirmation confirmation = repository.confirmReward(contribution, dining); + assertNotNull(confirmation, "confirmation should not be null"); + assertNotNull(confirmation.getConfirmationNumber(), "confirmation number should not be null"); + assertEquals(contribution, confirmation.getAccountContribution(), "wrong contribution object"); + verifyRewardInserted(confirmation, dining); + } + + private void verifyRewardInserted(RewardConfirmation confirmation, Dining dining) throws SQLException { + assertEquals(1, getRewardCount()); + Statement stmt = dataSource.getConnection().createStatement(); + ResultSet rs = stmt.executeQuery("select REWARD_AMOUNT from T_REWARD where CONFIRMATION_NUMBER = '" + + confirmation.getConfirmationNumber() + "'"); + rs.next(); + assertEquals(confirmation.getAccountContribution().getAmount(), MonetaryAmount.valueOf(rs.getString(1))); + } + + private int getRewardCount() throws SQLException { + Statement stmt = dataSource.getConnection().createStatement(); + ResultSet rs = stmt.executeQuery("select count(*) from T_REWARD"); + rs.next(); + return rs.getInt(1); + } + + private DataSource createTestDataSource() { + return new EmbeddedDatabaseBuilder() + .setName("rewards") + .addScript("/rewards/testdb/schema.sql") + .addScript("/rewards/testdb/data.sql") + .build(); + } +} diff --git a/lab/34-spring-data-jpa/src/test/resources/application.properties b/lab/34-spring-data-jpa/src/test/resources/application.properties new file mode 100644 index 0000000..829d64d --- /dev/null +++ b/lab/34-spring-data-jpa/src/test/resources/application.properties @@ -0,0 +1,16 @@ +# TO-DO-07 : Configure JPA +# +# - Define properties to make Spring Boot to run SQL +# scripts (test-schema.sql and test-data.sql) located under +# 'rewards.testdb' directory +# +# - Define Spring Boot properties to make JPA show +# the SQL it is running nicely formatted +# +# - Define a Spring Boot property to prevent hibernate +# from auto-creating and auto-populating database tables, +# our scripts did it already +# +# - Run RewardNetworkTests. Verify it succeeds. +# (If you are using Gradle, comment out test exclude +# statement from the build.gradle before running the test.) \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/test/resources/rewards/testdb/test-data.sql b/lab/34-spring-data-jpa/src/test/resources/rewards/testdb/test-data.sql new file mode 100644 index 0000000..3fd3972 --- /dev/null +++ b/lab/34-spring-data-jpa/src/test/resources/rewards/testdb/test-data.sql @@ -0,0 +1,6 @@ +insert into T_ACCOUNT (NUMBER, NAME, CREDIT_CARD) values ('123456789', 'Keith and Keri Donald', '1234123412341234'); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) values (0, 'Annabelle', 0.50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) values (0, 'Corgan', 0.50, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) values ('1234567890', 'AppleBees', 0.08, 'A'); +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) values ('1234567891', 'Barnabees', 1.00, 'N'); \ No newline at end of file diff --git a/lab/34-spring-data-jpa/src/test/resources/rewards/testdb/test-schema.sql b/lab/34-spring-data-jpa/src/test/resources/rewards/testdb/test-schema.sql new file mode 100644 index 0000000..d971e97 --- /dev/null +++ b/lab/34-spring-data-jpa/src/test/resources/rewards/testdb/test-schema.sql @@ -0,0 +1,18 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, CREDIT_CARD varchar(16), unique(NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(5,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(5,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal(8,2) not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; \ No newline at end of file diff --git a/lab/36-mvc-solution/build.gradle b/lab/36-mvc-solution/build.gradle new file mode 100644 index 0000000..2a194c8 --- /dev/null +++ b/lab/36-mvc-solution/build.gradle @@ -0,0 +1,10 @@ +apply plugin: "org.springframework.boot" + +dependencies { + implementation project(':00-rewards-common') + implementation project(':01-rewards-db') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'org.springframework.boot:spring-boot-starter-mustache' +} diff --git a/lab/36-mvc-solution/pom.xml b/lab/36-mvc-solution/pom.xml new file mode 100644 index 0000000..3ab9d3a --- /dev/null +++ b/lab/36-mvc-solution/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + 36-mvc-solution + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + UTF-8 + accounts.AccountsApplication + + + + + + io.spring.training.core-spring + 00-rewards-common + + + + + io.spring.training.core-spring + 01-rewards-db + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + org.springframework.boot + spring-boot-starter-mustache + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + org.apache.maven.plugins + maven-war-plugin + + + + diff --git a/lab/36-mvc-solution/src/main/java/accounts/AccountsApplication.java b/lab/36-mvc-solution/src/main/java/accounts/AccountsApplication.java new file mode 100644 index 0000000..88781c9 --- /dev/null +++ b/lab/36-mvc-solution/src/main/java/accounts/AccountsApplication.java @@ -0,0 +1,17 @@ +package accounts; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; + +import config.AccountsConfig; + +@SpringBootApplication +@Import(AccountsConfig.class) +public class AccountsApplication { + + public static void main(String[] args) { + SpringApplication.run(AccountsApplication.class, args); + } + +} diff --git a/lab/36-mvc-solution/src/main/java/accounts/web/AccountController.java b/lab/36-mvc-solution/src/main/java/accounts/web/AccountController.java new file mode 100644 index 0000000..5564623 --- /dev/null +++ b/lab/36-mvc-solution/src/main/java/accounts/web/AccountController.java @@ -0,0 +1,49 @@ +package accounts.web; + +import accounts.AccountManager; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; +import rewards.internal.account.Account; + +import java.util.List; + +/** + * A Spring MVC REST Controller handling requests to view Account information. + * + * Note that some of the Account related classes are imported from the + * rewards-db project: + * + * -Domain objects: Account and Beneficiary + * -Service layer: AccountManager interface + * -Repository layer: AccountRepository interface + * + */ +@RestController +public class AccountController { + + private final AccountManager accountManager; + + /** + * Creates a new AccountController with a given account manager. + */ + public AccountController(AccountManager accountManager) { + this.accountManager = accountManager; + } + + /** + * Provide a model with an account for the account detail page. + */ + @GetMapping("/accounts/{entityId}") + public Account accountDetails(@PathVariable("entityId") long id) { + return accountManager.getAccount(id); + } + + /** + * Provide a model with a list of all accounts for the account List page. + */ + @GetMapping("/accounts") + public List accountList() { + return accountManager.getAllAccounts(); + } +} diff --git a/lab/36-mvc-solution/src/main/java/config/AccountsConfig.java b/lab/36-mvc-solution/src/main/java/config/AccountsConfig.java new file mode 100644 index 0000000..2535a00 --- /dev/null +++ b/lab/36-mvc-solution/src/main/java/config/AccountsConfig.java @@ -0,0 +1,55 @@ +package config; + +import jakarta.persistence.EntityManager; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import accounts.AccountManager; +import accounts.internal.JpaAccountManager; + +/** + * Sets up the accounts database only. + */ +@Configuration +@EntityScan("rewards.internal") +public class AccountsConfig implements WebMvcConfigurer { + + /** + * A new service has been created for accessing Account information. Internally + * it uses JPA directly so no Repository class is required. + * + * @param entityManager + * The JPA Entity Manager (actually a proxy). + *

+ * Spring Boot initializes JPA automatically and Spring's JPA support + * injects a singleton EntityManager proxy by calling + * {@link JpaAccountManager#setEntityManager(EntityManager)}. + *

+ * At runtime this proxy resolves to the current EntityManager for + * the current transaction of the current thread. + * @return The new account-manager instance. + */ + @Bean + public AccountManager accountManager() { + return new JpaAccountManager(); + } + + /** + * Enables the home page which is using server-side rendering of a minimal view. + * + * @param registry + * View controller registry. Allows you to register simple mappings + * of URLs to static views (since there is no dynamic content a + * Spring Controller is not required). + */ + @Override + public void addViewControllers(ViewControllerRegistry registry) { + // Map the root URL to the index template + registry.addViewController("/").setViewName("index"); + } +} diff --git a/lab/36-mvc-solution/src/main/resources/application.properties b/lab/36-mvc-solution/src/main/resources/application.properties new file mode 100644 index 0000000..9e9243e --- /dev/null +++ b/lab/36-mvc-solution/src/main/resources/application.properties @@ -0,0 +1,18 @@ +# Change port number - just to show we can +server.port=8088 + +# Control how Boot loads data when it starts: +spring.jpa.hibernate.ddl-auto=none +spring.sql.init.schema-locations=classpath:/rewards/testdb/schema.sql +spring.sql.init.data-locations=classpath:/rewards/testdb/data.sql + + +# Logging for Spring Web components +logging.level.org.springframework.web=INFO + +# Reduce Hikari logging (DEBUG by default) +logging.level.com.zaxxer.hikari=INFO + +# Setup a MustacheViewResolver for the home page. +spring.mustache.prefix=classpath:/templates/ +spring.mustache.suffix=.html diff --git a/lab/36-mvc-solution/src/main/resources/templates/index.html b/lab/36-mvc-solution/src/main/resources/templates/index.html new file mode 100644 index 0000000..41e0d6e --- /dev/null +++ b/lab/36-mvc-solution/src/main/resources/templates/index.html @@ -0,0 +1,53 @@ + + + + + + + + + + + mvc: Home + + + + +

+
+ +
+ +
+ +

Accounts Service

+ + + +
+ +
+ + + \ No newline at end of file diff --git a/lab/36-mvc-solution/src/test/java/accounts/web/AccountControllerTests.java b/lab/36-mvc-solution/src/test/java/accounts/web/AccountControllerTests.java new file mode 100644 index 0000000..38e5bcd --- /dev/null +++ b/lab/36-mvc-solution/src/test/java/accounts/web/AccountControllerTests.java @@ -0,0 +1,50 @@ +package accounts.web; + +import accounts.internal.StubAccountManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import rewards.internal.account.Account; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A JUnit test case testing the AccountController. + */ +public class AccountControllerTests { + + private static final long expectedAccountId = StubAccountManager.TEST_ACCOUNT_ID; + private static final String expectedAccountNumber = StubAccountManager.TEST_ACCOUNT_NUMBER; + + private AccountController controller; + + @BeforeEach + public void setUp() { + controller = new AccountController(new StubAccountManager()); + } + + @Test + public void testHandleDetailsRequest() { + Account account = controller.accountDetails(0); + + assertNotNull(account); + assertEquals(expectedAccountId, (long) account.getEntityId()); + assertEquals(expectedAccountNumber, account.getNumber()); + } + + @Test + public void testHandleListRequest() { + List accounts = controller.accountList(); + + // Non-empty list containing the one and only test account + assertNotNull(accounts); + assertEquals(1, accounts.size()); + + // Validate that account + Account account = accounts.get(0); + assertEquals(expectedAccountId, (long) account.getEntityId()); + assertEquals(expectedAccountNumber, account.getNumber()); + } +} diff --git a/lab/36-mvc-solution/src/test/java/accounts/web/MockMvcTests.java b/lab/36-mvc-solution/src/test/java/accounts/web/MockMvcTests.java new file mode 100644 index 0000000..4abc884 --- /dev/null +++ b/lab/36-mvc-solution/src/test/java/accounts/web/MockMvcTests.java @@ -0,0 +1,80 @@ +package accounts.web; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Tests using Spring's MockMVC framework. This drives an MVC application in a + * test, as if it was running in a container, so far more checks are possible + * than with the simple {@link AccountControllerTests}. + */ +@SpringBootTest +@AutoConfigureMockMvc +public class MockMvcTests { + + @Autowired + private MockMvc mockMvc; + + /** + * Test a GET to /accountList. + *

+ * We tell the request that we will accept HTML then run the request by calling + * {@link MockMvc#perform(org.springframework.test.web.servlet.RequestBuilder)}. + *

+ * We can tell MockMVC what we expect in the response: status OK, a model + * containing one attribute that should be called "accounts" and rendered by + * using the "accountList" view. + * + * @throws Exception + * If anything fails. + */ + @Test + public void getAccountsTest() throws Exception { + int expectedNumberOfAccounts = 21; + + this.mockMvc // + .perform(get("/accounts") // + .accept(MediaType.parseMediaType("application/json"))) // + .andExpect(status().isOk()) // + .andExpect(content().contentType("application/json")) + .andExpect(jsonPath("$.length()").value(expectedNumberOfAccounts)); + } + + /** + * Test a GET to /accountDetails. + *

+ * We tell the request that we will accept HTML and specify the entityId + * parameter to be set to zero. Finally we run the request by invoking + * {@link MockMvc#perform(org.springframework.test.web.servlet.RequestBuilder)}. + *

+ * We can tell MockMVC what we expect in the response: status OK, a model + * containing one attribute that should be called "account" and rendered by + * using the "accountDetails" view. + * + * @throws Exception + * If anything fails. + */ + @Test + public void getAccountTest() throws Exception { + final String expectedAccountNumber = "123456789"; + final String expectedAccountName = "Keith and Keri Donald"; + + this.mockMvc.perform(get("/accounts/0") // + .accept(MediaType.parseMediaType("application/json"))) // + .andExpect(status().isOk()) // + .andExpect(content().contentType("application/json")) + .andExpect(jsonPath("$.number").value(expectedAccountNumber)) + .andExpect(jsonPath("$.name").value(expectedAccountName)); + } + +} diff --git a/lab/36-mvc/.springBeans b/lab/36-mvc/.springBeans new file mode 100644 index 0000000..1ddd973 --- /dev/null +++ b/lab/36-mvc/.springBeans @@ -0,0 +1,15 @@ + + + 1 + + + + + + + + + + + + diff --git a/lab/36-mvc/build.gradle b/lab/36-mvc/build.gradle new file mode 100644 index 0000000..e93ccee --- /dev/null +++ b/lab/36-mvc/build.gradle @@ -0,0 +1,10 @@ +apply plugin: "org.springframework.boot" + +dependencies { + implementation project(':00-rewards-common') + implementation project(':01-rewards-db') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'org.springframework.boot:spring-boot-starter-mustache' +} \ No newline at end of file diff --git a/lab/36-mvc/pom.xml b/lab/36-mvc/pom.xml new file mode 100644 index 0000000..b70810c --- /dev/null +++ b/lab/36-mvc/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + 36-mvc + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + UTF-8 + accounts.AccountsApplication + + + + + + io.spring.training.core-spring + 00-rewards-common + + + + + io.spring.training.core-spring + 01-rewards-db + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + org.springframework.boot + spring-boot-starter-mustache + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/lab/36-mvc/src/main/java/accounts/AccountsApplication.java b/lab/36-mvc/src/main/java/accounts/AccountsApplication.java new file mode 100644 index 0000000..3c46073 --- /dev/null +++ b/lab/36-mvc/src/main/java/accounts/AccountsApplication.java @@ -0,0 +1,43 @@ +package accounts; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; + +import config.AccountsConfig; + +/** + * Spring Boot application. + * + * TODO-00: In this lab, you are going to exercise the following: + * - Creating Spring Mvc REST controller + * - Implementing handlers that handle HTTP GET request + * - Using proper annotation for extracting value from the URL + * - Exercising Spring Dev Tools + * - Changing a port number of a Web application using server.port property + * - Writing assertions in the test code + * + * TODO-01: Open pom.xml or build.gradle for this project and check the dependencies. + * - Note that we are using Spring Boot starter for Web + * - Note that we are also using devtools + * + * TODO-02: Run the application as a Spring Boot or Java application + * - Access the home page: http://localhost:8080 + * - Click "List accounts as JSON" link in the homepage and note that it + * returns 404 - you will to implement it. + */ +@SpringBootApplication +@Import(AccountsConfig.class) +public class AccountsApplication { + + public static void main(String[] args) { + SpringApplication.run(AccountsApplication.class, args); + } + +} + +/** + * TODO-12: Make this server listen on port 8088. + * - Go to "application.properties" and set the appropriate property + * - Once the application restarts, access http://localhost:8088 + */ diff --git a/lab/36-mvc/src/main/java/accounts/web/AccountController.java b/lab/36-mvc/src/main/java/accounts/web/AccountController.java new file mode 100644 index 0000000..a3c2e20 --- /dev/null +++ b/lab/36-mvc/src/main/java/accounts/web/AccountController.java @@ -0,0 +1,70 @@ +package accounts.web; + +import accounts.AccountManager; +import rewards.internal.account.Account; + +import java.util.List; + +/** + * A Spring MVC REST Controller handling requests to retrieve Account information. + * + * Note that some of the Account related classes are imported from the + * rewards-db project: + * + * -Domain objects: Account and Beneficiary + * -Service layer: AccountManager interface + * -Repository layer: AccountRepository interface + * + */ +// TODO-03: Add an appropriate annotation to make this class a REST controller + +public class AccountController { + + private final AccountManager accountManager; + + /** + * Creates a new AccountController with a given account manager. + */ + public AccountController(AccountManager accountManager) { + this.accountManager = accountManager; + } + + /** + * Return a list of all accounts + */ + // TODO-04: Add an appropriate annotation to make this method handle "/accounts" + + public List accountList() { + + // TODO-05: Implement the logic to find and return all accounts + // - Use "accountManger" object to get all accounts + // - Recompile this class if necessary, and wait for the application to restart (via devtools) + // - From the home page, click the link - this should now work + // - If you prefer, access http://localhost:8080/accounts using curl or Postman + + return null; // REPLACE THIS LINE to return a list accounts + + // TODO-06: (If you are using STS) We are about to make lots of + // changes, so stop the application otherwise Devtools + // will keep restarting it. + } + + // TODO-08: Implement the /accounts/{entityId} request handling method. + // - Call the method accountDetails(). + // - Annotate to define URL mapping /accounts/{entityId} + // this method will respond to. + // - Use a method parameter to obtain the URI template parameter + // needed to retrieve an account. + // - Use the accountManager to obtain an account. This is the value to return + // - Save all work. + + + // TODO-10b: If AccountControllerTests.testHandleDetailsRequest() + // fails, fix errors before moving on + + // TODO-11: Run the application + // - You should now be able to invoke http://localhost:8080/accounts/N + // where N is 0-20 and get a response. You can use curl, Postman or + // your browser to do this. + +} diff --git a/lab/36-mvc/src/main/java/config/AccountsConfig.java b/lab/36-mvc/src/main/java/config/AccountsConfig.java new file mode 100644 index 0000000..bf3c59a --- /dev/null +++ b/lab/36-mvc/src/main/java/config/AccountsConfig.java @@ -0,0 +1,55 @@ +package config; + +import jakarta.persistence.EntityManager; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import accounts.AccountManager; +import accounts.internal.JpaAccountManager; + +/** + * Sets up the Accounts database only. + */ +@Configuration +@EntityScan("rewards.internal") +public class AccountsConfig implements WebMvcConfigurer { + + /** + * A new service has been created for accessing Account information. Internally + * it uses JPA directly so no Repository class is required. + * + * @param entityManager + * The JPA Entity Manager (actually a proxy). + *

+ * Spring Boot initializes JPA automatically and Spring's JPA support + * injects a singleton EntityManager proxy by calling + * {@link JpaAccountManager#setEntityManager(EntityManager)}. + *

+ * At runtime this proxy resolves to the current EntityManager for + * the current transaction of the current thread. + * @return The new account-manager instance. + */ + @Bean + public AccountManager accountManager() { + return new JpaAccountManager(); + } + + /** + * Enables the home page which is using server-side rendering of a minimal view. + * + * @param registry + * View controller registry. Allows you to register simple mappings + * of URLs to static views (since there is no dynamic content a + * Spring Controller is not required). + */ + @Override + public void addViewControllers(ViewControllerRegistry registry) { + // Map the root URL to the index template + registry.addViewController("/").setViewName("index"); + } +} diff --git a/lab/36-mvc/src/main/resources/application.properties b/lab/36-mvc/src/main/resources/application.properties new file mode 100644 index 0000000..f93a94d --- /dev/null +++ b/lab/36-mvc/src/main/resources/application.properties @@ -0,0 +1,18 @@ +# TO-DO-12: Set a property to make this server listen on port 8088. + + +# Control how Boot loads data when it starts: +spring.jpa.hibernate.ddl-auto=none +spring.sql.init.schema-locations=classpath:/rewards/testdb/schema.sql +spring.sql.init.data-locations=classpath:/rewards/testdb/data.sql + + +# Logging for Spring Web components +logging.level.org.springframework.web=INFO + +# Reduce Hikari logging (DEBUG by default) +logging.level.com.zaxxer.hikari=INFO + +# Setup a MustacheViewResolver for the home page. +spring.mustache.prefix=classpath:/templates/ +spring.mustache.suffix=.html diff --git a/lab/36-mvc/src/main/resources/templates/index.html b/lab/36-mvc/src/main/resources/templates/index.html new file mode 100644 index 0000000..41e0d6e --- /dev/null +++ b/lab/36-mvc/src/main/resources/templates/index.html @@ -0,0 +1,53 @@ + + + + + + + + + + + mvc: Home + + + + +

+
+ +
+ +
+ +

Accounts Service

+ + + +
+ +
+ + + \ No newline at end of file diff --git a/lab/36-mvc/src/test/java/accounts/web/AccountControllerTests.java b/lab/36-mvc/src/test/java/accounts/web/AccountControllerTests.java new file mode 100644 index 0000000..05ed660 --- /dev/null +++ b/lab/36-mvc/src/test/java/accounts/web/AccountControllerTests.java @@ -0,0 +1,59 @@ +package accounts.web; + +import accounts.internal.StubAccountManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import rewards.internal.account.Account; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A JUnit test case testing the AccountController. + */ +public class AccountControllerTests { + + private static final long expectedAccountId = StubAccountManager.TEST_ACCOUNT_ID; + private static final String expectedAccountNumber = StubAccountManager.TEST_ACCOUNT_NUMBER; + + private AccountController controller; + + @BeforeEach + public void setUp() { + controller = new AccountController(new StubAccountManager()); + } + + // TODO-07: Remove the @Disabled annotation, run the test, it should now pass. + @Test + @Disabled + public void testHandleListRequest() { + List accounts = controller.accountList(); + + // Non-empty list containing the one and only test account + assertNotNull(accounts); + assertEquals(1, accounts.size()); + + // Validate that account + Account account = accounts.get(0); + assertEquals(expectedAccountId, (long) account.getEntityId()); + assertEquals(expectedAccountNumber, account.getNumber()); + } + + // TODO-10a: Remove the @Disabled annotation, run the test, it should pass. + @Test + @Disabled + public void testHandleDetailsRequest() { + // TODO-09a: Implement test code which calls the accountDetails() method on the controller. + // - It will take one parameter - use "expectedAccountId" defined above + // - It will return an Account + + // TODO-09b: Define the following assertions: + // - The account is not null + // - The account id matches "expectedAccountId" defined above + // - The account number matches "expectedAccountNumber" defined above + } + +} diff --git a/lab/38-rest-ws-solution/build.gradle b/lab/38-rest-ws-solution/build.gradle new file mode 100644 index 0000000..cc0d526 --- /dev/null +++ b/lab/38-rest-ws-solution/build.gradle @@ -0,0 +1,14 @@ +apply plugin: "org.springframework.boot" + +dependencies { + implementation project(':00-rewards-common') + implementation project(':01-rewards-db') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + developmentOnly 'org.springframework.boot:spring-boot-devtools' +} + +test { + exclude '**/AccountClientTests.class' + exclude '**/AccountWebClientTests.class' +} \ No newline at end of file diff --git a/lab/38-rest-ws-solution/pom.xml b/lab/38-rest-ws-solution/pom.xml new file mode 100644 index 0000000..5547f3e --- /dev/null +++ b/lab/38-rest-ws-solution/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + 38-rest-ws-solution + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + UTF-8 + accounts.RestWsApplication + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + io.spring.training.core-spring + 00-rewards-common + + + + + io.spring.training.core-spring + 01-rewards-db + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/AccountClientTests.java + **/AccountWebClientTests.java + + + + + + diff --git a/lab/38-rest-ws-solution/src/main/java/accounts/RestWsApplication.java b/lab/38-rest-ws-solution/src/main/java/accounts/RestWsApplication.java new file mode 100644 index 0000000..0973dbc --- /dev/null +++ b/lab/38-rest-ws-solution/src/main/java/accounts/RestWsApplication.java @@ -0,0 +1,22 @@ +package accounts; + +import config.AppConfig; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.annotation.Import; + +@SpringBootApplication +@Import(AppConfig.class) +@EntityScan("rewards.internal") +public class RestWsApplication { + + public static void main(String[] args) { + + // SpringApplication.run(RestWsApplication.class, args); + new SpringApplicationBuilder(RestWsApplication.class) + .headless(false) + .run(args); + + } +} diff --git a/lab/38-rest-ws-solution/src/main/java/accounts/web/AccountController.java b/lab/38-rest-ws-solution/src/main/java/accounts/web/AccountController.java new file mode 100644 index 0000000..4c4e1bc --- /dev/null +++ b/lab/38-rest-ws-solution/src/main/java/accounts/web/AccountController.java @@ -0,0 +1,181 @@ +package accounts.web; + +import accounts.AccountManager; +import common.money.Percentage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; + +@RestController +public class AccountController { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final AccountManager accountManager; + + /** + * Creates a new AccountController with a given account manager. + */ + public AccountController(AccountManager accountManager) { + this.accountManager = accountManager; + } + + /** + * Provide a list of all accounts. + */ + @GetMapping(value = "/accounts") + public List accountSummary() { + return accountManager.getAllAccounts(); + } + + /** + * Provide the details of an account with the given id. + */ + @GetMapping(value = "/accounts/{id}") + public Account accountDetails(@PathVariable int id) { + return retrieveAccount(id); + } + + /** + * Creates a new Account, setting its URL as the Location header on the + * response. + */ + @PostMapping(value = "/accounts") + public ResponseEntity createAccount(@RequestBody Account newAccount) { + Account account = accountManager.save(newAccount); + return entityWithLocation(account.getEntityId()); + } + + /** + * Returns the Beneficiary with the given name for the Account with the + * given id. + */ + @GetMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + public Beneficiary getBeneficiary(@PathVariable int accountId, + @PathVariable String beneficiaryName) { + return retrieveAccount(accountId).getBeneficiary(beneficiaryName); + } + + /** + * Adds a Beneficiary with the given name to the Account with the given id, + * setting its URL as the Location header on the response. + */ + @PostMapping(value = "/accounts/{accountId}/beneficiaries") + public ResponseEntity addBeneficiary(@PathVariable long accountId, @RequestBody String beneficiaryName) { + accountManager.addBeneficiary(accountId, beneficiaryName); + return entityWithLocation(beneficiaryName); + } + + /** + * Removes the Beneficiary with the given name from the Account with the + * given id. + */ + @DeleteMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + @ResponseStatus(HttpStatus.NO_CONTENT) // 204 + public void removeBeneficiary(@PathVariable long accountId, @PathVariable String beneficiaryName) { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + Beneficiary deletedBeneficiary = account.getBeneficiary(beneficiaryName); + + HashMap allocationPercentages = new HashMap<>(); + + // If we are removing the only beneficiary or the beneficiary has an + // allocation of zero we don't need to worry. Otherwise, need to share + // out the benefit of the deleted beneficiary amongst all the others + if (account.getBeneficiaries().size() != 1 + && (!deletedBeneficiary.getAllocationPercentage().equals(Percentage.zero()))) { + // This logic is very simplistic, doesn't account for rounding errors + Percentage p = deletedBeneficiary.getAllocationPercentage(); + int remaining = account.getBeneficiaries().size() - 1; + double extra = p.asDouble() / remaining; + + for (Beneficiary beneficiary : account.getBeneficiaries()) { + if (beneficiary != deletedBeneficiary) { + double newValue = beneficiary.getAllocationPercentage().asDouble() + extra; + allocationPercentages.put(beneficiary.getName(), new Percentage(newValue)); + } + } + } + + accountManager.removeBeneficiary(accountId, beneficiaryName, allocationPercentages); + } + + /** + * Maps UnsupportedOperationException to a 501 Not Implemented HTTP status + * code. + */ + @ResponseStatus(HttpStatus.NOT_IMPLEMENTED) + @ExceptionHandler({ UnsupportedOperationException.class }) + public void handleUnabletoReallocate(Exception ex) { + logger.error("Exception is: ", ex); + // just return empty 501 + } + + /** + * Maps IllegalArgumentExceptions to a 404 Not Found HTTP status code. + */ + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(IllegalArgumentException.class) + public void handleNotFound(Exception ex) { + logger.error("Exception is: ", ex); + // return empty 404 + } + + /** + * Maps DataIntegrityViolationException to a 409 Conflict HTTP status code. + */ + @ResponseStatus(HttpStatus.CONFLICT) + @ExceptionHandler({ DataIntegrityViolationException.class }) + public void handleAlreadyExists(Exception ex) { + logger.error("Exception is: ", ex); + // return empty 409 + } + + /** + * Finds the Account with the given id, throwing an IllegalArgumentException + * if there is no such Account. + */ + private Account retrieveAccount(long accountId) throws IllegalArgumentException { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + return account; + } + + /** + * Return a response with the location of the new resource. + * + * Suppose we have just received an incoming URL of, say, + * http://localhost:8080/accounts and resourceId + * is "12345". Then the URL of the new resource will be + * http://localhost:8080/accounts/12345. + */ + private ResponseEntity entityWithLocation(Object resourceId) { + + // Determines URL of child resource based on the full URL of the given + // request, appending the path info with the given resource Identifier + URI location = ServletUriComponentsBuilder + .fromCurrentRequestUri() + .path("/{resourceId}") + .buildAndExpand(resourceId) + .toUri(); + + // Return an HttpEntity object - it will be used to build the + // HttpServletResponse + return ResponseEntity.created(location).build(); + } + +} diff --git a/lab/38-rest-ws-solution/src/main/resources/application.properties b/lab/38-rest-ws-solution/src/main/resources/application.properties new file mode 100644 index 0000000..50e7d17 --- /dev/null +++ b/lab/38-rest-ws-solution/src/main/resources/application.properties @@ -0,0 +1,7 @@ +# Control how Boot loads data when it starts: +spring.jpa.hibernate.ddl-auto=none +spring.sql.init.schema-locations=classpath:/rewards/testdb/schema.sql +spring.sql.init.data-locations=classpath:/rewards/testdb/data.sql + +# Suppress Netty related debug log messages +logging.level.io.netty.util.internal=ERROR \ No newline at end of file diff --git a/lab/38-rest-ws-solution/src/main/resources/static/README.md b/lab/38-rest-ws-solution/src/main/resources/static/README.md new file mode 100644 index 0000000..0e64b37 --- /dev/null +++ b/lab/38-rest-ws-solution/src/main/resources/static/README.md @@ -0,0 +1,7 @@ +# Spring Boot Resources + +By default, Spring Boot looks for resources in `classpath:static`, which is +this location. However `if src/main/webapp` exists it will look in there +first. + +Spring Boot is also conveniently configured to map '/' to `index.html`. \ No newline at end of file diff --git a/lab/38-rest-ws-solution/src/main/resources/static/index.html b/lab/38-rest-ws-solution/src/main/resources/static/index.html new file mode 100644 index 0000000..19ec648 --- /dev/null +++ b/lab/38-rest-ws-solution/src/main/resources/static/index.html @@ -0,0 +1,50 @@ + + + + + + + + + + + rest-ws-solution: RESTful web services with Spring Web MVC + + + + +
+
+ +
+ +
+ +

rest-ws-solution: RESTful applications with Spring MVC

+ +

This application is intended for programmatic clients, not browsers

+ +
+
+ + + diff --git a/lab/38-rest-ws-solution/src/test/java/accounts/client/AccountClientTests.java b/lab/38-rest-ws-solution/src/test/java/accounts/client/AccountClientTests.java new file mode 100644 index 0000000..7b56470 --- /dev/null +++ b/lab/38-rest-ws-solution/src/test/java/accounts/client/AccountClientTests.java @@ -0,0 +1,95 @@ +package accounts.client; + +import common.money.Percentage; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class AccountClientTests { + @LocalServerPort + private int port; + + private static final String BASE_URL = "http://localhost:"; + + private final RestTemplate restTemplate = new RestTemplate(); + private final Random random = new Random(); + + @Test + public void listAccounts() { + String url = BASE_URL + port + "/accounts"; + // we have to use Account[] instead of List, or Jackson won't know what type to unmarshal to + Account[] accounts = restTemplate.getForObject(url, Account[].class); + assertTrue(accounts.length >= 21, "Expected 21 accounts, but found " + accounts.length); + assertEquals("Keith and Keri Donald", accounts[0].getName()); + assertEquals(2, accounts[0].getBeneficiaries().size()); + assertEquals(Percentage.valueOf("50%"), accounts[0].getBeneficiary("Annabelle").getAllocationPercentage()); + } + + @Test + public void getAccount() { + String url = BASE_URL + port + "/accounts/{accountId}"; + Account account = restTemplate.getForObject(url, Account.class, 0); + assertEquals("Keith and Keri Donald", account.getName()); + assertEquals(2, account.getBeneficiaries().size()); + assertEquals(Percentage.valueOf("50%"), account.getBeneficiary("Annabelle").getAllocationPercentage()); + } + + @Test + public void createAccount() { + String url = BASE_URL + port + "/accounts"; + // use a unique number to avoid conflicts + String number = "12345%4d".formatted(random.nextInt(10000)); + Account account = new Account(number, "John Doe"); + account.addBeneficiary("Jane Doe"); + URI newAccountLocation = restTemplate.postForLocation(url, account); + + Account retrievedAccount = restTemplate.getForObject(newAccountLocation, Account.class); + assertEquals(account.getNumber(), retrievedAccount.getNumber()); + + Beneficiary accountBeneficiary = account.getBeneficiaries().iterator().next(); + Beneficiary retrievedAccountBeneficiary = retrievedAccount.getBeneficiaries().iterator().next(); + + assertEquals(accountBeneficiary.getName(), retrievedAccountBeneficiary.getName()); + assertNotNull(retrievedAccount.getEntityId()); + } + + @Test + public void createSameAccountTwiceResultsIn409() { + Account account = new Account("123123123", "John Doe"); + account.addBeneficiary("Jane Doe"); + + HttpClientErrorException httpClientErrorException = assertThrows(HttpClientErrorException.class, () -> { + restTemplate.postForObject(BASE_URL + port + "/accounts", account, Account.class); + restTemplate.postForObject(BASE_URL + port + "/accounts", account, Account.class); + }); + assertEquals(HttpStatus.CONFLICT, httpClientErrorException.getStatusCode()); + } + + @Test + public void addAndDeleteBeneficiary() { + // perform both add and delete to avoid issues with side effects + String addUrl = BASE_URL + port + "/accounts/{accountId}/beneficiaries"; + URI newBeneficiaryLocation = restTemplate.postForLocation(addUrl, "David", 1); + Beneficiary newBeneficiary = restTemplate.getForObject(newBeneficiaryLocation, Beneficiary.class); + assertEquals("David", newBeneficiary.getName()); + + restTemplate.delete(newBeneficiaryLocation); + + HttpClientErrorException httpClientErrorException = assertThrows(HttpClientErrorException.class, () -> { + System.out.println("You SHOULD get the exception \"No such beneficiary with name 'David'\" in the server."); + restTemplate.getForObject(newBeneficiaryLocation, Beneficiary.class); + }); + assertEquals(HttpStatus.NOT_FOUND, httpClientErrorException.getStatusCode()); + } +} diff --git a/lab/38-rest-ws-solution/src/test/java/accounts/client/AccountWebClientTests.java b/lab/38-rest-ws-solution/src/test/java/accounts/client/AccountWebClientTests.java new file mode 100644 index 0000000..9533e8c --- /dev/null +++ b/lab/38-rest-ws-solution/src/test/java/accounts/client/AccountWebClientTests.java @@ -0,0 +1,187 @@ +package accounts.client; + +import common.money.Percentage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class AccountWebClientTests { + + @LocalServerPort + private int port; + + /** + * server URL ending with the servlet mapping on which the application can be reached. + */ + private static final String BASE_URL = "http://localhost:"; + + private final Random random = new Random(); + private WebClient webClient; + + @BeforeEach + void setUp() { + webClient = WebClient.create(BASE_URL + port); + } + + @Test + public void listAccounts_WebClient_retrieve_blocking() { + + Account[] accounts = webClient.get() + .uri("/accounts") + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(Account[].class) + .block(); + + System.out.println(accounts.length); + assertTrue(accounts.length >= 21, "Expected 21 accounts, but found " + accounts.length); + assertEquals("Keith and Keri Donald", accounts[0].getName()); + assertEquals(2, accounts[0].getBeneficiaries().size()); + assertEquals(Percentage.valueOf("50%"), accounts[0].getBeneficiary("Annabelle").getAllocationPercentage()); + } + + @Test + public void listAccounts_WebClient_exchange_blocking() { + + Account[] accounts = webClient.get() + .uri("/accounts") + .accept(MediaType.APPLICATION_JSON) + .exchangeToMono(response -> response.bodyToMono(Account[].class)) + .block(); + + assertTrue(accounts.length >= 21, "Expected 21 accounts, but found " + accounts.length); + assertEquals("Keith and Keri Donald", accounts[0].getName()); + assertEquals(2, accounts[0].getBeneficiaries().size()); + assertEquals(Percentage.valueOf("50%"), accounts[0].getBeneficiary("Annabelle").getAllocationPercentage()); + } + + @Test + public void getAccount_WebClient_retrieve_blocking() { + + Account account = webClient.get() + .uri("/accounts/{id}", 0) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(Account.class) + .block(); + + assertEquals("Keith and Keri Donald", account.getName()); + assertEquals(2, account.getBeneficiaries().size()); + assertEquals(Percentage.valueOf("50%"), account.getBeneficiary("Annabelle").getAllocationPercentage()); + } + + @Test + public void getAccount_WebClient_exchange_blocking() { + + Account account = webClient.get() + .uri("/accounts/{id}", 0) + .accept(MediaType.APPLICATION_JSON) + .exchangeToMono(response -> response.bodyToMono(Account.class)) + .block(); + + assertEquals("Keith and Keri Donald", account.getName()); + assertEquals(2, account.getBeneficiaries().size()); + assertEquals(Percentage.valueOf("50%"), account.getBeneficiary("Annabelle").getAllocationPercentage()); + } + + @Test + public void createAccount_WebClient_blocking() throws Exception { + // use a unique number to avoid conflicts + String number = "12345%4d".formatted(random.nextInt(10000)); + Account account = new Account(number, "John Doe"); + account.addBeneficiary("Jane Doe"); + + ClientResponse clientResponse = webClient.post() + .uri("/accounts") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(account) + .exchangeToMono(Mono::just) + .block(); + + URI newAccountLocation = new URI(clientResponse.headers().header("Location").get(0)); + + Account retrievedAccount = webClient.get() + .uri(newAccountLocation) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(Account.class) + .block(); + + assertEquals(account.getNumber(), retrievedAccount.getNumber()); + + Beneficiary accountBeneficiary = account.getBeneficiaries().iterator().next(); + Beneficiary retrievedAccountBeneficiary = retrievedAccount.getBeneficiaries().iterator().next(); + + assertEquals(accountBeneficiary.getName(), retrievedAccountBeneficiary.getName()); + assertNotNull(retrievedAccount.getEntityId()); + } + + @Test + public void createSameAccountTwiceResultsIn409_WebClient_blocking() { + Account account = new Account("123123123", "John Doe"); + account.addBeneficiary("Jane Doe"); + + ClientResponse clientResponse = webClient.post() + .uri("/accounts") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(account) + .exchangeToMono(Mono::just) + .block(); + + //assertEquals(HttpStatus.CONFLICT, clientResponse.statusCode()); + assertEquals(HttpStatus.CREATED, clientResponse.statusCode()); + } + + @Test + public void addAndDeleteBeneficiary_WebClient_blocking() throws Exception { + // perform both add and delete to avoid issues with side effects + String addUrl = "/accounts/{accountId}/beneficiaries"; + + ClientResponse clientResponse = webClient.post() + .uri(addUrl, 1) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue("David") + .exchangeToMono(Mono::just) + .block(); + + URI newBeneficiaryLocation = new URI(clientResponse.headers().header("Location").get(0)); + + Beneficiary newBeneficiary= webClient.get() + .uri(newBeneficiaryLocation) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(Beneficiary.class) + .block(); + + assertEquals("David", newBeneficiary.getName()); + + clientResponse = webClient.delete() + .uri(newBeneficiaryLocation) + .exchangeToMono(Mono::just) + .block(); + + clientResponse = webClient.get() + .uri(newBeneficiaryLocation) + .accept(MediaType.APPLICATION_JSON) + .exchangeToMono(Mono::just) + .block(); + + assertEquals(HttpStatus.NOT_FOUND, clientResponse.statusCode()); + } + +} diff --git a/lab/38-rest-ws-solution/src/test/java/accounts/web/AccountControllerTests.java b/lab/38-rest-ws-solution/src/test/java/accounts/web/AccountControllerTests.java new file mode 100644 index 0000000..d0f6eba --- /dev/null +++ b/lab/38-rest-ws-solution/src/test/java/accounts/web/AccountControllerTests.java @@ -0,0 +1,113 @@ +package accounts.web; + +import accounts.internal.StubAccountManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * A JUnit test case testing the AccountController. + */ +public class AccountControllerTests { + + private AccountController controller; + + @BeforeEach + public void setUp() { + controller = new AccountController(new StubAccountManager()); + } + + @Test + public void testHandleDetailsRequest() { + Account account = controller.accountDetails(0); + assertNotNull(account); + assertEquals(Long.valueOf(0), account.getEntityId()); + } + + @Test + public void testHandleSummaryRequest() { + List accounts = controller.accountSummary(); + assertNotNull(accounts); + assertEquals(1, accounts.size()); + assertEquals(Long.valueOf(0), accounts.get(0).getEntityId()); + } + + @Test + public void testCreateAccount() { + Account newAccount = new Account("11223344", "Test"); + + // ServletUriComponentsBuilder expects to find the HttpRequest in the + // current thread (Spring MVC does this for you). For our test, we need + // to add a mock request manually + setupFakeRequest("http://localhost/accounts"); + + HttpEntity result = controller.createAccount(newAccount); + assertNotNull(result); + + // See StubAccountManager.nextEntityId - initialized to 3 + assertEquals("http://localhost/accounts/3", result.getHeaders().getLocation().toString()); + } + + @Test + public void testGetBeneficiary() { + Beneficiary beneficiary = controller.getBeneficiary(0, "Corgan"); + assertNotNull(beneficiary); + assertEquals(Long.valueOf(1), beneficiary.getEntityId()); + } + + @Test + public void testAddBeneficiary() { + + // ServletUriComponentsBuilder expects to find the HttpRequest in the + // current thread (Spring MVC does this for you). For our test, we need + // to add a mock request manually + setupFakeRequest("http://localhost/accounts/0/beneficiaries"); + + HttpEntity result = controller.addBeneficiary(0L, "Test2"); + assertNotNull(result); + assertEquals("http://localhost/accounts/0/beneficiaries/Test2", result.getHeaders().getLocation().toString()); + } + + @Test + public void testDeleteBeneficiary() { + controller.removeBeneficiary(0L, "Corgan"); + } + + @Test + public void testDeleteBeneficiaryFail() { + assertThrows(IllegalArgumentException.class, () -> { + controller.removeBeneficiary(0L, "Fred"); + }); + } + + /** + * Add a mocked up HttpServletRequest to Spring's internal request-context + * holder. Normally the DispatcherServlet does this, but we must do it + * manually to run our test. + * + * @param url + * The URL we are creating the fake request for. + */ + private void setupFakeRequest(String url) { + String requestURI = url.substring(16); // Drop "http://localhost" + + // We can use Spring's convenient mock implementation. Defaults to + // localhost in the URL. Since we only need the URL, we don't need + // to setup anything else in the request. + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestURI); + + // Puts the fake request in the current thread for the + // ServletUriComponentsBuilder to initialize itself from later. + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + +} diff --git a/lab/38-rest-ws/build.gradle b/lab/38-rest-ws/build.gradle new file mode 100644 index 0000000..8ce5246 --- /dev/null +++ b/lab/38-rest-ws/build.gradle @@ -0,0 +1,8 @@ +apply plugin: "org.springframework.boot" + +dependencies { + implementation project(':00-rewards-common') + implementation project(':01-rewards-db') + implementation 'org.springframework.boot:spring-boot-starter-web' + developmentOnly 'org.springframework.boot:spring-boot-devtools' +} diff --git a/lab/38-rest-ws/pom.xml b/lab/38-rest-ws/pom.xml new file mode 100644 index 0000000..35fd868 --- /dev/null +++ b/lab/38-rest-ws/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + 38-rest-ws + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + UTF-8 + accounts.RestWsApplication + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + io.spring.training.core-spring + 00-rewards-common + + + io.spring.training.core-spring + 01-rewards-db + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/AccountClientTests.java + + + + + + diff --git a/lab/38-rest-ws/src/main/java/accounts/RestWsApplication.java b/lab/38-rest-ws/src/main/java/accounts/RestWsApplication.java new file mode 100644 index 0000000..d69600b --- /dev/null +++ b/lab/38-rest-ws/src/main/java/accounts/RestWsApplication.java @@ -0,0 +1,30 @@ +package accounts; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Import; + +import config.AppConfig; + +@SpringBootApplication +@Import(AppConfig.class) +@EntityScan("rewards.internal") +public class RestWsApplication { + + public static void main(String[] args) { + SpringApplication.run(RestWsApplication.class, args); + } + + // TODO-00: In this lab, you are going to exercise the following: + // - Implementing controller handlers that handle HTTP POST, PUT, DELETE requests + // - Using proper annotation for extracting data from incoming request + // - Creating URI for a newly created item in handling HTTP POST request + // - Exercising RestTemplate for sending HTTP requests to the server application + + // TODO-01: Run this Spring Boot application + // - IMPORTANT: Make sure that you are not still running an application + // from a previous lab. + // - Verify you can reach http://localhost:8080 from a browser or curl. + +} diff --git a/lab/38-rest-ws/src/main/java/accounts/web/AccountController.java b/lab/38-rest-ws/src/main/java/accounts/web/AccountController.java new file mode 100644 index 0000000..9ce89c4 --- /dev/null +++ b/lab/38-rest-ws/src/main/java/accounts/web/AccountController.java @@ -0,0 +1,176 @@ +package accounts.web; + +import accounts.AccountManager; +import common.money.Percentage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.util.HashMap; +import java.util.List; + +/** + * A controller handling requests for CRUD operations on Accounts and their + * Beneficiaries. + */ +@RestController +public class AccountController { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final AccountManager accountManager; + + /** + * Creates a new AccountController with a given account manager. + */ + public AccountController(AccountManager accountManager) { + this.accountManager = accountManager; + } + + /** + * Provide a list of all accounts. + */ + // TODO-02: Review the code that performs the following + // a. Respond to GET /accounts + // b. Return a List to be converted to the response body + // - Access http://localhost:8080/accounts using a browser or curl + // and verify that you see the list of accounts in JSON format. + @GetMapping(value = "/accounts") + public List accountSummary() { + return accountManager.getAllAccounts(); + } + + /** + * Provide the details of an account with the given id. + */ + // TODO-04: Review the code that performs the following + // a. Respond to GET /accounts/{accountId} + // b. Return an Account to be converted to the response body + // - Access http://localhost:8080/accounts/0 using a browser or curl + // and verify that you see the account detail in JSON format + @GetMapping(value = "/accounts/{id}") + public Account accountDetails(@PathVariable int id) { + return retrieveAccount(id); + } + + /** + * Creates a new Account, setting its URL as the Location header on the + * response. + */ + // TODO-06: Complete this method. Add annotations to: + // a. Respond to POST /accounts requests + // b. Use a proper annotation for creating an Account object from the request + public ResponseEntity createAccount(Account newAccount) { + // Saving the account also sets its entity Id + Account account = accountManager.save(newAccount); + + // Return a ResponseEntity - it will be used to build the + // HttpServletResponse. + return entityWithLocation(account.getEntityId()); + } + + /** + * Return a response with the location of the new resource. + * + * Suppose we have just received an incoming URL of, say, + * http://localhost:8080/accounts and resourceId is "1111". + * Then the URL of the new resource will be + * http://localhost:8080/accounts/1111. + */ + private ResponseEntity entityWithLocation(Object resourceId) { + + // TODO-07: Set the 'location' header on a Response to URI of + // the newly created resource and return it. + // a. You will need to use 'ServletUriComponentsBuilder' and + // 'ResponseEntity' to implement this - Use ResponseEntity.created(..) + // b. Refer to the POST example in the slides for more information + + return null; // Return something other than null + } + + /** + * Returns the Beneficiary with the given name for the Account with the + * given id. + */ + @GetMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + public Beneficiary getBeneficiary(@PathVariable int accountId, + @PathVariable String beneficiaryName) { + return retrieveAccount(accountId).getBeneficiary(beneficiaryName); + } + + /** + * Adds a Beneficiary with the given name to the Account with the given id, + * setting its URL as the Location header on the response. + */ + // TODO-10: Complete this method. Add annotations to: + // a. Respond to a POST /accounts/{accountId}/beneficiaries + // b. Extract a beneficiary name from the incoming request + // c. Indicate a "201 Created" status + public ResponseEntity addBeneficiary(long accountId, String beneficiaryName) { + + // TODO-11: Create a ResponseEntity containing the location of the newly + // created beneficiary. + // a. Use accountManager's addBeneficiary method to add a beneficiary to an account + // b. Use the entityWithLocation method - like we did for createAccount(). + + return null; // Modify this to return something + } + + /** + * Removes the Beneficiary with the given name from the Account with the + * given id. + */ + // TODO-12: Complete this method by adding the appropriate annotations to: + // a. Respond to a DELETE to /accounts/{accountId}/beneficiaries/{beneficiaryName} + // b. Indicate a "204 No Content" status + public void removeBeneficiary(long accountId, String beneficiaryName) { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + Beneficiary b = account.getBeneficiary(beneficiaryName); + + // We ought to reset the allocation percentages, but for now we won't + // bother. If we are removing the only beneficiary or the beneficiary + // has an allocation of zero we don't need to worry. Otherwise, throw an + // exception. + if (account.getBeneficiaries().size() != 1 && (!b.getAllocationPercentage().equals(Percentage.zero()))) { + // The solution has the missing logic, if you are interested. + throw new RuntimeException("Logic to rebalance Beneficiaries not defined."); + } + + accountManager.removeBeneficiary(accountId, beneficiaryName, new HashMap<>()); + } + + /** + * Maps IllegalArgumentExceptions to a 404 Not Found HTTP status code. + */ + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler({ IllegalArgumentException.class }) + public void handleNotFound(Exception ex) { + logger.error("Exception is: ", ex); + // just return empty 404 + } + + // TODO-17 (Optional): Add a new exception-handling method + // - It should map DataIntegrityViolationException to a 409 Conflict status code. + // - Use the handleNotFound method above for guidance. + // - Consult the lab document for further instruction + + /** + * Finds the Account with the given id, throwing an IllegalArgumentException + * if there is no such Account. + */ + private Account retrieveAccount(long accountId) throws IllegalArgumentException { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + return account; + } + +} diff --git a/lab/38-rest-ws/src/main/resources/application.properties b/lab/38-rest-ws/src/main/resources/application.properties new file mode 100644 index 0000000..e4d9f9b --- /dev/null +++ b/lab/38-rest-ws/src/main/resources/application.properties @@ -0,0 +1,4 @@ +# Control how Boot loads data when it starts: +spring.jpa.hibernate.ddl-auto=none +spring.sql.init.schema-locations=classpath:/rewards/testdb/schema.sql +spring.sql.init.data-locations=classpath:/rewards/testdb/data.sql \ No newline at end of file diff --git a/lab/38-rest-ws/src/main/resources/static/README.md b/lab/38-rest-ws/src/main/resources/static/README.md new file mode 100644 index 0000000..0e64b37 --- /dev/null +++ b/lab/38-rest-ws/src/main/resources/static/README.md @@ -0,0 +1,7 @@ +# Spring Boot Resources + +By default, Spring Boot looks for resources in `classpath:static`, which is +this location. However `if src/main/webapp` exists it will look in there +first. + +Spring Boot is also conveniently configured to map '/' to `index.html`. \ No newline at end of file diff --git a/lab/38-rest-ws/src/main/resources/static/index.html b/lab/38-rest-ws/src/main/resources/static/index.html new file mode 100644 index 0000000..986cc59 --- /dev/null +++ b/lab/38-rest-ws/src/main/resources/static/index.html @@ -0,0 +1,50 @@ + + + + + + + + + + + rest-ws: RESTful web services with Spring Web MVC + + + + +
+
+ +
+ +
+ +

rest-ws: RESTful applications with Spring MVC

+ +

This application is intended for programmatic clients, not browsers

+ +
+
+ + + diff --git a/lab/38-rest-ws/src/test/java/accounts/client/AccountClientTests.java b/lab/38-rest-ws/src/test/java/accounts/client/AccountClientTests.java new file mode 100644 index 0000000..133d550 --- /dev/null +++ b/lab/38-rest-ws/src/test/java/accounts/client/AccountClientTests.java @@ -0,0 +1,119 @@ +package accounts.client; + +import common.money.Percentage; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +public class AccountClientTests { + + private static final String BASE_URL = "http://localhost:8080"; + + private final RestTemplate restTemplate = new RestTemplate(); + private final Random random = new Random(); + + @Test + @Disabled + public void listAccounts() { + // TODO-03: Run this test + // - Remove the @Disabled on this test method. + // - Then, use the restTemplate to retrieve an array containing all Account instances. + // - Use BASE_URL to help define the URL you need: BASE_URL + "/..." + // - Run the test and ensure that it passes. + Account[] accounts = null; // Modify this line to use the restTemplate + + assertNotNull(accounts); + assertTrue(accounts.length >= 21); + assertEquals("Keith and Keri Donald", accounts[0].getName()); + assertEquals(2, accounts[0].getBeneficiaries().size()); + assertEquals(Percentage.valueOf("50%"), accounts[0].getBeneficiary("Annabelle").getAllocationPercentage()); + } + + @Test + @Disabled + public void getAccount() { + // TODO-05: Run this test + // - Remove the @Disabled on this test method. + // - Then, use the restTemplate to retrieve the Account with id 0 using a URI template + // - Run the test and ensure that it passes. + Account account = null; // Modify this line to use the restTemplate + + assertNotNull(account); + assertEquals("Keith and Keri Donald", account.getName()); + assertEquals(2, account.getBeneficiaries().size()); + assertEquals(Percentage.valueOf("50%"), account.getBeneficiary("Annabelle").getAllocationPercentage()); + } + + @Test + @Disabled + public void createAccount() { + // Use a unique number to avoid conflicts + String number = "12345%4d".formatted(random.nextInt(10000)); + Account account = new Account(number, "John Doe"); + account.addBeneficiary("Jane Doe"); + + // TODO-08: Create a new Account + // - Remove the @Disabled on this test method. + // - Create a new Account by POSTing to the right URL and + // store its location in a variable + // - Note that 'RestTemplate' has two methods for this. + // - Use the one that returns the location of the newly created + // resource and assign that to a variable. + URI newAccountLocation = null; // Modify this line to use the restTemplate + + // TODO-09: Retrieve the Account you just created from + // the location that was returned. + // - Run this test, then. Make sure the test succeeds. + Account retrievedAccount = null; // Modify this line to use the restTemplate + + assertEquals(account.getNumber(), retrievedAccount.getNumber()); + + Beneficiary accountBeneficiary = account.getBeneficiaries().iterator().next(); + Beneficiary retrievedAccountBeneficiary = retrievedAccount.getBeneficiaries().iterator().next(); + + assertEquals(accountBeneficiary.getName(), retrievedAccountBeneficiary.getName()); + assertNotNull(retrievedAccount.getEntityId()); + } + + @Test + @Disabled + public void addAndDeleteBeneficiary() { + // perform both add and delete to avoid issues with side effects + + // TODO-13: Create a new Beneficiary + // - Remove the @Disabled on this test method. + // - Create a new Beneficiary called "David" for the account with id 1 + // (POST the String "David" to the "/accounts/{accountId}/beneficiaries" URL). + // - Store the returned location URI in a variable. + + // TODO-14: Retrieve the Beneficiary you just created from the location that was returned + Beneficiary newBeneficiary = null; // Modify this line to use the restTemplate + + assertNotNull(newBeneficiary); + assertEquals("David", newBeneficiary.getName()); + + // TODO-15: Delete the newly created Beneficiary + + + HttpClientErrorException httpClientErrorException = assertThrows(HttpClientErrorException.class, () -> { + System.out.println("You SHOULD get the exception \"No such beneficiary with name 'David'\" in the server."); + + // TODO-16: Try to retrieve the newly created Beneficiary again. + // - Run this test, then. It should pass because we expect a 404 Not Found + // If not, it is likely your delete in the previous step + // was not successful. + + }); + assertEquals(HttpStatus.NOT_FOUND, httpClientErrorException.getStatusCode()); + } + +} diff --git a/lab/38-rest-ws/src/test/java/accounts/web/AccountControllerTests.java b/lab/38-rest-ws/src/test/java/accounts/web/AccountControllerTests.java new file mode 100644 index 0000000..8ce9f8e --- /dev/null +++ b/lab/38-rest-ws/src/test/java/accounts/web/AccountControllerTests.java @@ -0,0 +1,40 @@ +package accounts.web; + +import accounts.internal.StubAccountManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import rewards.internal.account.Account; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * A JUnit test case testing the AccountController. + */ +public class AccountControllerTests { + + private AccountController controller; + + @BeforeEach + public void setUp() { + controller = new AccountController(new StubAccountManager()); + } + + @Test + public void testHandleDetailsRequest() { + Account account = controller.accountDetails(0); + assertNotNull(account); + assertEquals(Long.valueOf(0), account.getEntityId()); + } + + @Test + public void testHandleSummaryRequest() { + List accounts = controller.accountSummary(); + assertNotNull(accounts); + assertEquals(1, accounts.size()); + assertEquals(Long.valueOf(0), accounts.get(0).getEntityId()); + } + +} diff --git a/lab/40-boot-test-solution/.springBeans b/lab/40-boot-test-solution/.springBeans new file mode 100644 index 0000000..345f3c0 --- /dev/null +++ b/lab/40-boot-test-solution/.springBeans @@ -0,0 +1,15 @@ + + + 1 + + + + + + + + + + + + diff --git a/lab/40-boot-test-solution/build.gradle b/lab/40-boot-test-solution/build.gradle new file mode 100644 index 0000000..1d8c477 --- /dev/null +++ b/lab/40-boot-test-solution/build.gradle @@ -0,0 +1,10 @@ +apply plugin: "org.springframework.boot" + +dependencies { + implementation project(':00-rewards-common') + implementation project(':01-rewards-db') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + developmentOnly 'org.springframework.boot:spring-boot-devtools' +} diff --git a/lab/40-boot-test-solution/pom.xml b/lab/40-boot-test-solution/pom.xml new file mode 100644 index 0000000..6a6b4f0 --- /dev/null +++ b/lab/40-boot-test-solution/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + 40-boot-test-solution + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + UTF-8 + accounts.BootTestSolutionApplication + + + + + io.spring.training.core-spring + 00-rewards-common + + + + + io.spring.training.core-spring + 01-rewards-db + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/lab/40-boot-test-solution/src/main/java/accounts/BootTestSolutionApplication.java b/lab/40-boot-test-solution/src/main/java/accounts/BootTestSolutionApplication.java new file mode 100644 index 0000000..e351a0f --- /dev/null +++ b/lab/40-boot-test-solution/src/main/java/accounts/BootTestSolutionApplication.java @@ -0,0 +1,19 @@ +package accounts; + +import config.AppConfig; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Import; + +/** + * Runs the Account Server. + */ +@SpringBootApplication +public class BootTestSolutionApplication { + + public static void main(String[] args) { + SpringApplication.run(BootTestSolutionApplication.class, args); + } + +} diff --git a/lab/40-boot-test-solution/src/main/java/accounts/RewardsConfig.java b/lab/40-boot-test-solution/src/main/java/accounts/RewardsConfig.java new file mode 100644 index 0000000..c5bc102 --- /dev/null +++ b/lab/40-boot-test-solution/src/main/java/accounts/RewardsConfig.java @@ -0,0 +1,14 @@ +package accounts; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import config.AppConfig; + +@Configuration +@Import(AppConfig.class) +@EntityScan("rewards.internal") +public class RewardsConfig { + +} \ No newline at end of file diff --git a/lab/40-boot-test-solution/src/main/java/accounts/web/AccountController.java b/lab/40-boot-test-solution/src/main/java/accounts/web/AccountController.java new file mode 100644 index 0000000..4fef015 --- /dev/null +++ b/lab/40-boot-test-solution/src/main/java/accounts/web/AccountController.java @@ -0,0 +1,187 @@ +package accounts.web; + +import accounts.AccountManager; +import common.money.Percentage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; + +/** + * A controller handling requests for CRUD operations on Accounts and their + * Beneficiaries. + */ +@RestController +public class AccountController { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final AccountManager accountManager; + + /** + * Creates a new AccountController with a given account manager. + */ + public AccountController(AccountManager accountManager) { + this.accountManager = accountManager; + } + + /** + * Provide a list of all accounts. + */ + @GetMapping(value = "/accounts") + public List accountSummary() { + return accountManager.getAllAccounts(); + } + + /** + * Provide the details of an account with the given id. + */ + @GetMapping(value = "/accounts/{id}") + public Account accountDetails(@PathVariable int id) { + return retrieveAccount(id); + } + + /** + * Creates a new Account, setting its URL as the Location header on the + * response. + */ + @PostMapping(value = "/accounts") + public ResponseEntity createAccount(@RequestBody Account newAccount) { + Account account = accountManager.save(newAccount); + return entityWithLocation(account.getEntityId()); + } + + /** + * Returns the Beneficiary with the given name for the Account with the + * given id. + */ + @GetMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + public Beneficiary getBeneficiary(@PathVariable int accountId, + @PathVariable String beneficiaryName) { + return retrieveAccount(accountId).getBeneficiary(beneficiaryName); + } + + /** + * Adds a Beneficiary with the given name to the Account with the given id, + * setting its URL as the Location header on the response. + */ + @PostMapping(value = "/accounts/{accountId}/beneficiaries") + public ResponseEntity addBeneficiary(@PathVariable long accountId, @RequestBody String beneficiaryName) { + accountManager.addBeneficiary(accountId, beneficiaryName); + return entityWithLocation(beneficiaryName); + } + + /** + * Removes the Beneficiary with the given name from the Account with the + * given id. + */ + @DeleteMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + @ResponseStatus(HttpStatus.NO_CONTENT) // 204 + public void removeBeneficiary(@PathVariable long accountId, @PathVariable String beneficiaryName) { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + Beneficiary deletedBeneficiary = account.getBeneficiary(beneficiaryName); + + HashMap allocationPercentages = new HashMap<>(); + + // If we are removing the only beneficiary or the beneficiary has an + // allocation of zero we don't need to worry. Otherwise, need to share + // out the benefit of the deleted beneficiary amongst all the others + if (account.getBeneficiaries().size() != 1 + && (!deletedBeneficiary.getAllocationPercentage().equals(Percentage.zero()))) { + // This logic is very simplistic, doesn't account for rounding errors + Percentage p = deletedBeneficiary.getAllocationPercentage(); + int remaining = account.getBeneficiaries().size() - 1; + double extra = p.asDouble() / remaining; + + for (Beneficiary beneficiary : account.getBeneficiaries()) { + if (beneficiary != deletedBeneficiary) { + double newValue = beneficiary.getAllocationPercentage().asDouble() + extra; + allocationPercentages.put(beneficiary.getName(), new Percentage(newValue)); + } + } + } + + accountManager.removeBeneficiary(accountId, beneficiaryName, allocationPercentages); + } + + /** + * Maps UnsupportedOperationException to a 501 Not Implemented HTTP status + * code. + */ + @ResponseStatus(HttpStatus.NOT_IMPLEMENTED) + @ExceptionHandler({ UnsupportedOperationException.class }) + public void handleUnabletoReallocate(Exception ex) { + logger.error("Exception is: ", ex); + // just return empty 501 + } + + /** + * Maps IllegalArgumentExceptions to a 404 Not Found HTTP status code. + */ + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(IllegalArgumentException.class) + public void handleNotFound(Exception ex) { + logger.error("Exception is: ", ex); + // return empty 404 + } + + /** + * Maps DataIntegrityViolationException to a 409 Conflict HTTP status code. + */ + @ResponseStatus(HttpStatus.CONFLICT) + @ExceptionHandler({ DataIntegrityViolationException.class }) + public void handleAlreadyExists(Exception ex) { + logger.error("Exception is: ", ex); + // return empty 409 + } + + /** + * Finds the Account with the given id, throwing an IllegalArgumentException + * if there is no such Account. + */ + private Account retrieveAccount(long accountId) throws IllegalArgumentException { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + return account; + } + + /** + * Return a response with the location of the new resource. Its URL is + * assumed to be a child of the URL just received. + *

+ * Suppose we have just received an incoming URL of, say, + * http://localhost:8080/accounts and resourceId + * is "12345". Then the URL of the new resource will be + * http://localhost:8080/accounts/12345. + * + * @param resourceId + * Is of the new resource. + * @return + */ + private ResponseEntity entityWithLocation(Object resourceId) { + + // Determines URL of child resource based on the full URL of the given + // request, appending the path info with the given resource Identifier + URI location = ServletUriComponentsBuilder.fromCurrentRequestUri().path("/{childId}").buildAndExpand(resourceId) + .toUri(); + + // Return an HttpEntity object - it will be used to build the + // HttpServletResponse + return ResponseEntity.created(location).build(); + } + +} diff --git a/lab/40-boot-test-solution/src/main/resources/application.properties b/lab/40-boot-test-solution/src/main/resources/application.properties new file mode 100644 index 0000000..3f7cb91 --- /dev/null +++ b/lab/40-boot-test-solution/src/main/resources/application.properties @@ -0,0 +1,8 @@ +# Control how Boot loads data when it starts: +spring.sql.init.schema-locations=classpath:/rewards/testdb/schema.sql +spring.sql.init.data-locations=classpath:/rewards/testdb/data.sql + +# Setup JPA +spring.jpa.show-sql=true +spring.jpa.hibernate.ddl-auto=none +spring.jpa.properties.hibernate.format_sql=true diff --git a/lab/40-boot-test-solution/src/main/resources/static/README.md b/lab/40-boot-test-solution/src/main/resources/static/README.md new file mode 100644 index 0000000..0e64b37 --- /dev/null +++ b/lab/40-boot-test-solution/src/main/resources/static/README.md @@ -0,0 +1,7 @@ +# Spring Boot Resources + +By default, Spring Boot looks for resources in `classpath:static`, which is +this location. However `if src/main/webapp` exists it will look in there +first. + +Spring Boot is also conveniently configured to map '/' to `index.html`. \ No newline at end of file diff --git a/lab/40-boot-test-solution/src/main/resources/static/index.html b/lab/40-boot-test-solution/src/main/resources/static/index.html new file mode 100644 index 0000000..415cea3 --- /dev/null +++ b/lab/40-boot-test-solution/src/main/resources/static/index.html @@ -0,0 +1,50 @@ + + + + + + + + + + + boot-test-solution: Testing with Spring Boot + + + + +

+
+ +
+ +
+ +

boot-test-solution: Testing with Spring Boot

+ +

This application is intended for programmatic clients, not browsers

+ +
+
+ + + diff --git a/lab/40-boot-test-solution/src/test/java/accounts/client/AccountClientTests.java b/lab/40-boot-test-solution/src/test/java/accounts/client/AccountClientTests.java new file mode 100644 index 0000000..675d1f0 --- /dev/null +++ b/lab/40-boot-test-solution/src/test/java/accounts/client/AccountClientTests.java @@ -0,0 +1,82 @@ +package accounts.client; + +import common.money.Percentage; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.util.Random; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +public class AccountClientTests { + + @Autowired + private TestRestTemplate restTemplate; + + private final Random random = new Random(); + + @Test + public void listAccounts() { + String url = "/accounts"; + // we have to use Account[] instead of List, or Jackson won't know what type to unmarshal to + Account[] accounts = restTemplate.getForObject(url, Account[].class); + assertThat(accounts.length >= 21).isTrue(); + assertThat(accounts[0].getName()).isEqualTo("Keith and Keri Donald"); + assertThat(accounts[0].getBeneficiaries().size()).isEqualTo(2); + assertThat(accounts[0].getBeneficiary("Annabelle").getAllocationPercentage()).isEqualTo(Percentage.valueOf("50%")); + } + + @Test + public void getAccount() { + String url = "/accounts/{accountId}"; + Account account = restTemplate.getForObject(url, Account.class, 0); + assertThat(account.getName()).isEqualTo("Keith and Keri Donald"); + assertThat(account.getBeneficiaries().size()).isEqualTo(2); + assertThat(account.getBeneficiary("Annabelle").getAllocationPercentage()).isEqualTo(Percentage.valueOf("50%")); + } + + @Test + public void createAccount() { + String url = "/accounts"; + // use a unique number to avoid conflicts + String number = "12345%4d".formatted(random.nextInt(10000)); + Account account = new Account(number, "John Doe"); + account.addBeneficiary("Jane Doe"); + URI newAccountLocation = restTemplate.postForLocation(url, account); + + Account retrievedAccount = restTemplate.getForObject(newAccountLocation, Account.class); + assertThat(retrievedAccount.getNumber()).isEqualTo(account.getNumber()); + + Beneficiary accountBeneficiary = account.getBeneficiaries().iterator().next(); + Beneficiary retrievedAccountBeneficiary = retrievedAccount.getBeneficiaries().iterator().next(); + + assertThat(retrievedAccountBeneficiary.getName()).isEqualTo(accountBeneficiary.getName()); + assertThat(retrievedAccount.getEntityId()).isNotNull(); + } + + @Test + public void addAndDeleteBeneficiary() { + + String addUrl = "/accounts/{accountId}/beneficiaries"; + URI newBeneficiaryLocation = restTemplate.postForLocation(addUrl, "David", 1); + Beneficiary newBeneficiary = restTemplate.getForObject(newBeneficiaryLocation, Beneficiary.class); + assertThat(newBeneficiary.getName()).isEqualTo("David"); + + restTemplate.delete(newBeneficiaryLocation); + + ResponseEntity response = + restTemplate.getForEntity(newBeneficiaryLocation, Beneficiary.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + +} diff --git a/lab/40-boot-test-solution/src/test/java/accounts/client/AccountWebTestClientTests.java b/lab/40-boot-test-solution/src/test/java/accounts/client/AccountWebTestClientTests.java new file mode 100644 index 0000000..ce4ab4a --- /dev/null +++ b/lab/40-boot-test-solution/src/test/java/accounts/client/AccountWebTestClientTests.java @@ -0,0 +1,150 @@ +package accounts.client; + +import common.money.Percentage; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Random; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +public class AccountWebTestClientTests { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private WebTestClient webTestClient; + + private final Random random = new Random(); + + @Test + public void listAccounts_WebTestClient() { + String url = "/accounts"; + + webTestClient.get().uri(url) + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectHeader().contentType(MediaType.APPLICATION_JSON) + .expectBody(Account[].class) + .consumeWith(response -> { + Account[] accounts = response.getResponseBody(); + assertThat(accounts.length >= 21).isTrue(); + assertThat(accounts[0].getName()).isEqualTo("Keith and Keri Donald"); + assertThat(accounts[0].getBeneficiaries().size()).isEqualTo(2); + assertThat(accounts[0].getBeneficiary("Annabelle").getAllocationPercentage()).isEqualTo(Percentage.valueOf("50%")); + }); + } + + @Test + public void getAccount_WebTestClient() { + String url = "/accounts/{accountId}"; + + webTestClient.get() + .uri(url, 0) + .exchange() + .expectStatus().isOk() + .expectBody(Account.class) + .consumeWith(response -> { + Account account = response.getResponseBody(); + assertThat(account.getName()).isEqualTo("Keith and Keri Donald"); + assertThat(account.getBeneficiaries().size()).isEqualTo(2); + assertThat(account.getBeneficiary("Annabelle").getAllocationPercentage()).isEqualTo(Percentage.valueOf("50%")); + }); + + } + + @Test + public void createAccount_WebTestClient() { + String url = "/accounts"; + // use a unique number to avoid conflicts + String number = "12345%4d".formatted(random.nextInt(10000)); + Account account = new Account(number, "John Doe"); + account.addBeneficiary("Jane Doe"); + + webTestClient.post() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just(account), Account.class) + .exchange() + .expectStatus().isCreated() + .expectHeader().value("Location", location -> { + try { + URI newAccountLocation = new URI(location); + webTestClient.get() + .uri(newAccountLocation) + .exchange() + .expectStatus().isOk() + .expectBody(Account.class) + .consumeWith(response -> { + Account retrievedAccount = response.getResponseBody(); + assertThat(retrievedAccount.getNumber()).isEqualTo(account.getNumber()); + + Beneficiary accountBeneficiary = account.getBeneficiaries().iterator().next(); + Beneficiary retrievedAccountBeneficiary = retrievedAccount.getBeneficiaries().iterator().next(); + + assertThat(retrievedAccountBeneficiary.getName()).isEqualTo(accountBeneficiary.getName()); + assertThat(retrievedAccount.getEntityId()).isNotNull(); + }); + } catch (URISyntaxException e) { + e.printStackTrace(); + } + }); + + } + + @Test + public void addAndDeleteBeneficiary_WebTestClient() { + + String addUrl = "/accounts/{accountId}/beneficiaries"; + + webTestClient.post().uri(addUrl, 1) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(Mono.just("David"), String.class) + .exchange() + .expectStatus().isCreated() + .expectHeader().value("Location", location -> { + try { + URI newBeneficiaryLocation = new URI(location); + webTestClient.get() + .uri(newBeneficiaryLocation) + .exchange() + .expectStatus().isOk() + .expectBody(Beneficiary.class) + .consumeWith(response -> { + Beneficiary newBeneficiary = response.getResponseBody(); + assertThat(newBeneficiary.getName()).isEqualTo("David"); + }); + + webTestClient.delete() + .uri(newBeneficiaryLocation) + .exchange() + .expectStatus().isNoContent(); + + webTestClient.get() + .uri(newBeneficiaryLocation) + .exchange() + .expectStatus().isNotFound(); + + } catch (URISyntaxException e) { + e.printStackTrace(); + } + }); + + } + +} diff --git a/lab/40-boot-test-solution/src/test/java/accounts/web/AccountControllerBootTests.java b/lab/40-boot-test-solution/src/test/java/accounts/web/AccountControllerBootTests.java new file mode 100644 index 0000000..dcda633 --- /dev/null +++ b/lab/40-boot-test-solution/src/test/java/accounts/web/AccountControllerBootTests.java @@ -0,0 +1,169 @@ +package accounts.web; + +import accounts.AccountManager; +import com.fasterxml.jackson.databind.ObjectMapper; +import common.money.Percentage; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import rewards.internal.account.Account; + +import jakarta.persistence.EntityManagerFactory; +import javax.sql.DataSource; +import java.util.Arrays; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * These tests run the AccountController using the MockMVC framework. + * The server does not need to be running. + */ +@WebMvcTest(AccountController.class) // WebMvcTest = MockMvc, @MockBean +public class AccountControllerBootTests { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AccountManager accountManager; + + @Test + public void accountDetails() throws Exception { + + // arrange + given(accountManager.getAccount(anyLong())) + .willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0")) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")) + .andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(anyLong()); + + } + + @Test + public void accountDetailsFail() throws Exception { + + given(accountManager.getAccount(any(Long.class))) + .willThrow(new IllegalArgumentException("No such account with id " + 0L)); + + mockMvc.perform(get("/accounts/9999")) + .andExpect(status().isNotFound()); + + verify(accountManager).getAccount(any(Long.class)); + + } + + @Test + public void accountSummary() throws Exception { + + List testAccounts = List.of(new Account("123456789", "John Doe")); + given(accountManager.getAllAccounts()) + .willReturn(testAccounts); + + mockMvc.perform(get("/accounts")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$..number").value("123456789")) + .andExpect(jsonPath("$..name").value("John Doe")); + + verify(accountManager).getAllAccounts(); + + } + + @Test + public void createAccount() throws Exception { + + Account testAccount = new Account("1234512345", "Mary Jones"); + testAccount.setEntityId(21L); + + given(accountManager.save(any(Account.class))) + .willReturn(testAccount); + + mockMvc.perform(post("/accounts") + .contentType(MediaType.APPLICATION_JSON) + .content(asJsonString(testAccount))) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "http://localhost/accounts/21")); + + verify(accountManager).save(any(Account.class)); + + } + + @Test + public void getBeneficiary() throws Exception { + + Account account = new Account("1234567890", "John Doe"); + account.addBeneficiary("Corgan", new Percentage(0.1)); + + given(accountManager.getAccount(anyLong())) + .willReturn(account); + + mockMvc.perform(get("/accounts/{accountId}/beneficiaries/{beneficiaryName}", 0L, "Corgan")) + .andExpect(status().isOk()).andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("Corgan")) + .andExpect(jsonPath("allocationPercentage").value("0.1")); + + verify(accountManager).getAccount(anyLong()); + } + + @Test + public void addBeneficiary() throws Exception { + + mockMvc.perform(post("/accounts/{entityId}/beneficiaries", 0L) + .content("Kate")) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "http://localhost/accounts/0/beneficiaries/Kate")); + } + + @Test + public void removeBeneficiary() throws Exception { + + Account account = new Account("1234567890", "John Doe"); + account.addBeneficiary("Corgan", new Percentage(0.1)); + given(accountManager.getAccount(anyLong())).willReturn(account); + + mockMvc.perform(delete("/accounts/{entityId}/beneficiaries/{name}", 0L, "Corgan")) + .andExpect(status().isNoContent()); + + verify(accountManager).getAccount(anyLong()); + + } + + @Test + public void removeBeneficiaryFail() throws Exception { + Account account = new Account("1234567890", "John Doe"); + given(accountManager.getAccount(anyLong())).willReturn(account); + + mockMvc.perform(delete("/accounts/{entityId}/beneficiaries/{name}", 0L, "Noname")) + .andExpect(status().isNotFound()); + + verify(accountManager).getAccount(anyLong()); + } + + protected static String asJsonString(final Object obj) { + try { + final ObjectMapper mapper = new ObjectMapper(); + return mapper.writeValueAsString(obj); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + +} diff --git a/lab/40-boot-test-solution/src/test/java/accounts/web/AccountControllerTests.java b/lab/40-boot-test-solution/src/test/java/accounts/web/AccountControllerTests.java new file mode 100644 index 0000000..5341812 --- /dev/null +++ b/lab/40-boot-test-solution/src/test/java/accounts/web/AccountControllerTests.java @@ -0,0 +1,118 @@ +package accounts.web; + +import accounts.internal.StubAccountManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +//import org.junit.Assert; + +/** + * A JUnit test case testing the AccountController. + */ +public class AccountControllerTests { + + private AccountController controller; + + @BeforeEach + public void setUp() { + controller = new AccountController(new StubAccountManager()); + } + + @Test + public void accountDetails() { + Account account = controller.accountDetails(0); + assertThat(account).isNotNull(); + assertThat(account.getEntityId()).isEqualTo(Long.valueOf(0)); + } + + @Test + public void accountSummary() { + List accounts = controller.accountSummary(); + assertThat(accounts).isNotNull(); + assertThat(accounts.size()).isEqualTo(1); + assertThat(accounts.get(0).getEntityId()).isEqualTo(Long.valueOf(0)); + } + + @Test + public void createAccount() { + Account newAccount = new Account("11223344", "Test"); + + // ServletUriComponentsBuilder expects to find the HttpRequest in the + // current thread (Spring MVC does this for you). For our test, we need + // to add a mock request manually + setupFakeRequest("http://localhost/accounts"); + + HttpEntity result = controller.createAccount(newAccount); + assertThat(result).isNotNull(); + + // See StubAccountManager.nextEntityId - initialized to 3 + assertThat(result.getHeaders().getLocation().toString()).isEqualTo("http://localhost/accounts/3"); + } + + @Test + public void getBeneficiary() { + Beneficiary beneficiary = controller.getBeneficiary(0, "Corgan"); + assertThat(beneficiary).isNotNull(); + assertThat(beneficiary.getEntityId()).isEqualTo(Long.valueOf(1)); + } + + @Test + public void addBeneficiary() { + + // ServletUriComponentsBuilder expects to find the HttpRequest in the + // current thread (Spring MVC does this for you). For our test, we need + // to add a mock request manually + setupFakeRequest("http://localhost/accounts/0/beneficiaries"); + + HttpEntity result = controller.addBeneficiary(0L, "Test2"); + assertThat(result).isNotNull(); + assertThat(result.getHeaders().getLocation().toString()).isEqualTo("http://localhost/accounts/0/beneficiaries/Test2"); + } + + @Test + public void removeBeneficiary() { + controller.removeBeneficiary(0L, "Corgan"); + } + + @Test + public void removeBeneficiaryFail() { + + assertThrows(IllegalArgumentException.class, () -> { + controller.removeBeneficiary(0L, "Fred"); + }, "No such beneficiary 'Fred', " + "IllegalArgumentException expected"); + } + + /** + * Add a mocked up HttpServletRequest to Spring's internal request-context + * holder. Normally the DispatcherServlet does this, but we must do it + * manually to run our test. + * + * @param url + * The URL we are creating the fake request for. + */ + private void setupFakeRequest(String url) { + String requestURI = url.substring(16); // Drop "http://localhost" + + // We can use Spring's convenient mock implementation. Defaults to + // localhost in the URL. Since we only need the URL, we don't need + // to setup anything else in the request. + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestURI); + + // Puts the fake request in the current thread for the + // ServletUriComponentsBuilder to initialize itself from later. + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + +} + diff --git a/lab/40-boot-test/.springBeans b/lab/40-boot-test/.springBeans new file mode 100644 index 0000000..345f3c0 --- /dev/null +++ b/lab/40-boot-test/.springBeans @@ -0,0 +1,15 @@ + + + 1 + + + + + + + + + + + + diff --git a/lab/40-boot-test/build.gradle b/lab/40-boot-test/build.gradle new file mode 100644 index 0000000..8dd05fe --- /dev/null +++ b/lab/40-boot-test/build.gradle @@ -0,0 +1,13 @@ +apply plugin: "org.springframework.boot" + +dependencies { + implementation project(':00-rewards-common') + implementation project(':01-rewards-db') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + developmentOnly 'org.springframework.boot:spring-boot-devtools' +} + +test { + exclude '**/AccountClientTests.class' +} \ No newline at end of file diff --git a/lab/40-boot-test/pom.xml b/lab/40-boot-test/pom.xml new file mode 100644 index 0000000..e9bae47 --- /dev/null +++ b/lab/40-boot-test/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + 40-boot-test + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + UTF-8 + accounts.BootTestApplication + + + + + io.spring.training.core-spring + 00-rewards-common + + + + + io.spring.training.core-spring + 01-rewards-db + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/AccountClientTests.java + + + + + + diff --git a/lab/40-boot-test/src/main/java/accounts/BootTestApplication.java b/lab/40-boot-test/src/main/java/accounts/BootTestApplication.java new file mode 100644 index 0000000..683baac --- /dev/null +++ b/lab/40-boot-test/src/main/java/accounts/BootTestApplication.java @@ -0,0 +1,20 @@ +package accounts; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Import; + +import config.AppConfig; + +/** + * Runs the Account Server. + */ +@SpringBootApplication +public class BootTestApplication { + + public static void main(String[] args) { + SpringApplication.run(BootTestApplication.class, args); + } + +} diff --git a/lab/40-boot-test/src/main/java/accounts/RewardsConfig.java b/lab/40-boot-test/src/main/java/accounts/RewardsConfig.java new file mode 100644 index 0000000..c5bc102 --- /dev/null +++ b/lab/40-boot-test/src/main/java/accounts/RewardsConfig.java @@ -0,0 +1,14 @@ +package accounts; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import config.AppConfig; + +@Configuration +@Import(AppConfig.class) +@EntityScan("rewards.internal") +public class RewardsConfig { + +} \ No newline at end of file diff --git a/lab/40-boot-test/src/main/java/accounts/web/AccountController.java b/lab/40-boot-test/src/main/java/accounts/web/AccountController.java new file mode 100644 index 0000000..5b98a2d --- /dev/null +++ b/lab/40-boot-test/src/main/java/accounts/web/AccountController.java @@ -0,0 +1,188 @@ +package accounts.web; + +import accounts.AccountManager; +import common.money.Percentage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; + +/** + * A controller handling requests for CRUD operations on Accounts and their + * Beneficiaries. + */ +@RestController +public class AccountController { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final AccountManager accountManager; + + /** + * Creates a new AccountController with a given account manager. + */ + public AccountController(AccountManager accountManager) { + this.accountManager = accountManager; + } + + /** + * Provide a list of all accounts. + */ + @GetMapping(value = "/accounts") + public List accountSummary() { + return accountManager.getAllAccounts(); + } + + /** + * Provide the details of an account with the given id. + */ + @GetMapping(value = "/accounts/{id}") + public Account accountDetails(@PathVariable int id) { + return retrieveAccount(id); + } + + /** + * Creates a new Account, setting its URL as the Location header on the + * response. + */ + @PostMapping(value = "/accounts") + public ResponseEntity createAccount(@RequestBody Account newAccount) { + Account account = accountManager.save(newAccount); + return entityWithLocation(account.getEntityId()); + } + + /** + * Returns the Beneficiary with the given name for the Account with the + * given id. + */ + @GetMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + public Beneficiary getBeneficiary(@PathVariable int accountId, + @PathVariable String beneficiaryName) { + return retrieveAccount(accountId).getBeneficiary(beneficiaryName); + } + + /** + * Adds a Beneficiary with the given name to the Account with the given id, + * setting its URL as the Location header on the response. + */ + @PostMapping(value = "/accounts/{accountId}/beneficiaries") + public ResponseEntity addBeneficiary(@PathVariable long accountId, @RequestBody String beneficiaryName) { + accountManager.addBeneficiary(accountId, beneficiaryName); + return entityWithLocation(beneficiaryName); + } + + /** + * Removes the Beneficiary with the given name from the Account with the + * given id. + */ + @DeleteMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + @ResponseStatus(HttpStatus.NO_CONTENT) // 204 + public void removeBeneficiary(@PathVariable long accountId, @PathVariable String beneficiaryName) { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + + Beneficiary deletedBeneficiary = account.getBeneficiary(beneficiaryName); + + HashMap allocationPercentages = new HashMap<>(); + + // If we are removing the only beneficiary or the beneficiary has an + // allocation of zero we don't need to worry. Otherwise, need to share + // out the benefit of the deleted beneficiary amongst all the others + if (account.getBeneficiaries().size() != 1 + && (!deletedBeneficiary.getAllocationPercentage().equals(Percentage.zero()))) { + // This logic is very simplistic, doesn't account for rounding errors + Percentage p = deletedBeneficiary.getAllocationPercentage(); + int remaining = account.getBeneficiaries().size() - 1; + double extra = p.asDouble() / remaining; + + for (Beneficiary beneficiary : account.getBeneficiaries()) { + if (beneficiary != deletedBeneficiary) { + double newValue = beneficiary.getAllocationPercentage().asDouble() + extra; + allocationPercentages.put(beneficiary.getName(), new Percentage(newValue)); + } + } + } + + accountManager.removeBeneficiary(accountId, beneficiaryName, allocationPercentages); + } + + /** + * Maps UnsupportedOperationException to a 501 Not Implemented HTTP status + * code. + */ + @ResponseStatus(HttpStatus.NOT_IMPLEMENTED) + @ExceptionHandler({ UnsupportedOperationException.class }) + public void handleUnabletoReallocate(Exception ex) { + logger.error("Exception is: ", ex); + // just return empty 501 + } + + /** + * Maps IllegalArgumentExceptions to a 404 Not Found HTTP status code. + */ + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(IllegalArgumentException.class) + public void handleNotFound(Exception ex) { + logger.error("Exception is: ", ex); + // return empty 404 + } + + /** + * Maps DataIntegrityViolationException to a 409 Conflict HTTP status code. + */ + @ResponseStatus(HttpStatus.CONFLICT) + @ExceptionHandler({ DataIntegrityViolationException.class }) + public void handleAlreadyExists(Exception ex) { + logger.error("Exception is: ", ex); + // return empty 409 + } + + /** + * Finds the Account with the given id, throwing an IllegalArgumentException + * if there is no such Account. + */ + private Account retrieveAccount(long accountId) throws IllegalArgumentException { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + return account; + } + + /** + * Return a response with the location of the new resource. Its URL is + * assumed to be a child of the URL just received. + *

+ * Suppose we have just received an incoming URL of, say, + * http://localhost:8080/accounts and resourceId + * is "12345". Then the URL of the new resource will be + * http://localhost:8080/accounts/12345. + * + * @param resourceId + * Is of the new resource. + * @return + */ + private ResponseEntity entityWithLocation(Object resourceId) { + + // Determines URL of child resource based on the full URL of the given + // request, appending the path info with the given resource Identifier + URI location = ServletUriComponentsBuilder.fromCurrentRequestUri().path("/{childId}").buildAndExpand(resourceId) + .toUri(); + + // Return an HttpEntity object - it will be used to build the + // HttpServletResponse + return ResponseEntity.created(location).build(); + } + +} diff --git a/lab/40-boot-test/src/main/resources/application.properties b/lab/40-boot-test/src/main/resources/application.properties new file mode 100644 index 0000000..3f7cb91 --- /dev/null +++ b/lab/40-boot-test/src/main/resources/application.properties @@ -0,0 +1,8 @@ +# Control how Boot loads data when it starts: +spring.sql.init.schema-locations=classpath:/rewards/testdb/schema.sql +spring.sql.init.data-locations=classpath:/rewards/testdb/data.sql + +# Setup JPA +spring.jpa.show-sql=true +spring.jpa.hibernate.ddl-auto=none +spring.jpa.properties.hibernate.format_sql=true diff --git a/lab/40-boot-test/src/main/resources/static/README.md b/lab/40-boot-test/src/main/resources/static/README.md new file mode 100644 index 0000000..0e64b37 --- /dev/null +++ b/lab/40-boot-test/src/main/resources/static/README.md @@ -0,0 +1,7 @@ +# Spring Boot Resources + +By default, Spring Boot looks for resources in `classpath:static`, which is +this location. However `if src/main/webapp` exists it will look in there +first. + +Spring Boot is also conveniently configured to map '/' to `index.html`. \ No newline at end of file diff --git a/lab/40-boot-test/src/main/resources/static/index.html b/lab/40-boot-test/src/main/resources/static/index.html new file mode 100644 index 0000000..73ef8c7 --- /dev/null +++ b/lab/40-boot-test/src/main/resources/static/index.html @@ -0,0 +1,50 @@ + + + + + + + + + + + boot-test: Testing with Spring Boot + + + + +

+
+ +
+ +
+ +

boot-test: Testing with Spring Boot

+ +

This application is intended for programmatic clients, not browsers

+ +
+
+ + + diff --git a/lab/40-boot-test/src/test/java/accounts/client/AccountClientTests.java b/lab/40-boot-test/src/test/java/accounts/client/AccountClientTests.java new file mode 100644 index 0000000..9c8f9eb --- /dev/null +++ b/lab/40-boot-test/src/test/java/accounts/client/AccountClientTests.java @@ -0,0 +1,124 @@ +package accounts.client; + +import common.money.Percentage; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.util.Random; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +// TODO-00: In this lab, you are going to exercise the following: +// - Using @SpringBootTest and webEnvironment for end-to-end testing +// (You are going to refactor the test code of previous lab of "38-rest-ws" +// to use Spring Boot test framework.) +// - Understanding the different usage model of TestRestTemplate from RestTemplate +// * Usage of a relative path rather than an absolute path +// * Handling the 404 response from the service +// - Using MockMvc for Web slice testing +// - Understanding the difference between @MockBean and @Mock + +// TODO-01: Make this class a Spring Boot test class +// - Add @SpringBootTest annotation with WebEnvironment.RANDOM_PORT + +public class AccountClientTests { + + // TODO-02: Autowire TestRestTemplate bean to a field + // - Name the field as restTemplate + + // TODO-03: Update code below to use TestRestTemplate (as opposed to RestTemplate) + // - Remove RestTemplate from this code + // - Remove BASE_URL from this code or change the value of it to "" + // - Run the tests and observe that they pass except + // "addAndDeleteBeneficiary" test + // (If you are using Gradle, remove test exclude statement + // from the build.gradle before running these tests) + + /** + * server URL ending with the servlet mapping on which the application can be + * reached. + */ + private static final String BASE_URL = "http://localhost:8080"; + + private final RestTemplate restTemplate = new RestTemplate(); + private final Random random = new Random(); + + @Test + public void listAccounts() { + String url = BASE_URL + "/accounts"; + // we have to use Account[] instead of List, or Jackson won't know what + // type to unmarshal to + Account[] accounts = restTemplate.getForObject(url, Account[].class); + assertThat(accounts.length >= 21).isTrue(); + assertThat(accounts[0].getName()).isEqualTo("Keith and Keri Donald"); + assertThat(accounts[0].getBeneficiaries().size()).isEqualTo(2); + assertThat(accounts[0].getBeneficiary("Annabelle").getAllocationPercentage()).isEqualTo(Percentage.valueOf("50%")); + } + + @Test + public void getAccount() { + String url = BASE_URL + "/accounts/{accountId}"; + Account account = restTemplate.getForObject(url, Account.class, 0); + assertThat(account.getName()).isEqualTo("Keith and Keri Donald"); + assertThat(account.getBeneficiaries().size()).isEqualTo(2); + assertThat(account.getBeneficiary("Annabelle").getAllocationPercentage()).isEqualTo(Percentage.valueOf("50%")); + } + + @Test + public void createAccount() { + String url = BASE_URL + "/accounts"; + // use a random account number to avoid conflict + String number = "12345%4d".formatted(random.nextInt(10000)); + Account account = new Account(number, "John Doe"); + account.addBeneficiary("Jane Doe"); + URI newAccountLocation = restTemplate.postForLocation(url, account); + + Account retrievedAccount = restTemplate.getForObject(newAccountLocation, Account.class); + assertThat(retrievedAccount.getNumber()).isEqualTo(account.getNumber()); + + Beneficiary accountBeneficiary = account.getBeneficiaries().iterator().next(); + Beneficiary retrievedAccountBeneficiary = retrievedAccount.getBeneficiaries().iterator().next(); + + assertThat(retrievedAccountBeneficiary.getName()).isEqualTo(accountBeneficiary.getName()); + assertThat(retrievedAccount.getEntityId()).isNotNull(); + } + + // TODO-04: Modify the code below so that it handles 404 HTTP response status + // from the server (instead of handling it as an exception as in the + // case of RestTemplate) + // - Remove the "assertThrows" statement (since you are not going to + // check if an exception is thrown) + // - Use "getForEntity" method (instead of "getForObject" method) of + // "TestRestTemplate" + // - Verify that the HTTP response status is 404 + // - Run all tests - they should all pass + @Test + public void addAndDeleteBeneficiary() { + // perform both add and delete to avoid issues with side effects + String addUrl = BASE_URL + "/accounts/{accountId}/beneficiaries"; + URI newBeneficiaryLocation = restTemplate.postForLocation(addUrl, "David", 1); + Beneficiary newBeneficiary = restTemplate.getForObject(newBeneficiaryLocation, Beneficiary.class); + assertThat(newBeneficiary.getName()).isEqualTo("David"); + + restTemplate.delete(newBeneficiaryLocation); + + HttpClientErrorException httpClientErrorException = assertThrows(HttpClientErrorException.class, () -> { + System.out.println("You SHOULD get the exception \"No such beneficiary with name 'David'\" in the server."); + restTemplate.getForObject(newBeneficiaryLocation, Beneficiary.class); + }); + assertThat(httpClientErrorException.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + // TODO-05: Observe a log message in the console indicating + // Tomcat started as part of testing + // - Search for "Tomcat started on port(s):" + // - Note how long it takes for this test to finish - it is + // in the range of several seconds + +} diff --git a/lab/40-boot-test/src/test/java/accounts/web/AccountControllerBootTests.java b/lab/40-boot-test/src/test/java/accounts/web/AccountControllerBootTests.java new file mode 100644 index 0000000..dfd5bc0 --- /dev/null +++ b/lab/40-boot-test/src/test/java/accounts/web/AccountControllerBootTests.java @@ -0,0 +1,103 @@ +package accounts.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +// TODO-06: Get yourself familiarized with various testing utility classes +// - Uncomment the import statements below +//import static org.mockito.ArgumentMatchers.any; +//import static org.mockito.BDDMockito.*; +//import static org.mockito.Mockito.verify; +//import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +//import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +// TODO-07: Replace @ExtendWith(SpringExtension.class) with the following annotation +// - @WebMvcTest(AccountController.class) // includes @ExtendWith(SpringExtension.class) +@ExtendWith(SpringExtension.class) +public class AccountControllerBootTests { + + // TODO-08: Autowire MockMvc bean + + // TODO-09: Create AccountManager mock bean using @MockBean annotation + + // TODO-10: Write positive test for GET request for an account + // - Uncomment the code and run the test and verify it succeeds + @Test + public void accountDetails() { + + //given(accountManager.getAccount(0L)) + // .willReturn(new Account("1234567890", "John Doe")); + + //mockMvc.perform(get("/accounts/0")) + // .andExpect(status().isOk()) + // .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + // .andExpect(jsonPath("name").value("John Doe")) + // .andExpect(jsonPath("number").value("1234567890")); + + //verify(accountManager).getAccount(0L); + + } + + // TODO-11: Write negative test for GET request for a non-existent account + // - Uncomment the "given" and "verify" statements + // - Write code between the "given" and "verify" statements + // - Run the test and verify it succeeds + @Test + public void accountDetailsFail() { + + //given(accountManager.getAccount(any(Long.class))) + // .willThrow(new IllegalArgumentException("No such account with id " + 0L)); + + // (Write code here) + // - Use mockMvc to perform HTTP Get operation using "/accounts/9999" + // as a non-existent account URL + // - Verify that the HTTP response status is 404 + + //verify(accountManager).getAccount(any(Long.class)); + + } + + // TODO-12: Write test for `POST` request for an account + // - Uncomment Java code below + // - Write code between the "given" and "verify" statements + // - Run the test and verify it succeeds + @Test + public void createAccount() { + + //Account testAccount = new Account("1234512345", "Mary Jones"); + //testAccount.setEntityId(21L); + + //given(accountManager.save(any(Account.class))) + // .willReturn(testAccount); + + // (Write code here) + // Use mockMvc to perform HTTP Post operation to "/accounts" + // - Set the request content type to APPLICATION_JSON + // - Set the request content with Json string of the "testAccount" + // (Use "asJsonString" method below to convert the "testAccount" + // object into Json string) + // - Verify that the response status is 201 + // - Verify that the response "Location" header contains "http://localhost/accounts/21" + + //verify(accountManager).save(any(Account.class)); + + } + + // Utility class for converting an object into JSON string + protected static String asJsonString(final Object obj) { + try { + final ObjectMapper mapper = new ObjectMapper(); + return mapper.writeValueAsString(obj); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + // TODO-13 (Optional): Experiment with @MockBean vs @Mock + // - Change `@MockBean` to `@Mock` for the `AccountManager dependency above + // - Run the test and observe a test failure + // - Change it back to `@MockBean` + +} diff --git a/lab/40-boot-test/src/test/java/accounts/web/AccountControllerTests.java b/lab/40-boot-test/src/test/java/accounts/web/AccountControllerTests.java new file mode 100644 index 0000000..04ef681 --- /dev/null +++ b/lab/40-boot-test/src/test/java/accounts/web/AccountControllerTests.java @@ -0,0 +1,115 @@ +package accounts.web; + +import accounts.internal.StubAccountManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * A JUnit test case testing the AccountController. + */ +public class AccountControllerTests { + + private AccountController controller; + + @BeforeEach + public void setUp() { + controller = new AccountController(new StubAccountManager()); + } + + @Test + public void accountDetails() { + Account account = controller.accountDetails(0); + assertThat(account).isNotNull(); + assertThat(account.getEntityId()).isEqualTo(Long.valueOf(0)); + } + + @Test + public void accountSummary() { + List accounts = controller.accountSummary(); + assertThat(accounts).isNotNull(); + assertThat(accounts.size()).isEqualTo(1); + assertThat(accounts.get(0).getEntityId()).isEqualTo(Long.valueOf(0)); + } + + @Test + public void createAccount() { + Account newAccount = new Account("11223344", "Test"); + + // ServletUriComponentsBuilder expects to find the HttpRequest in the + // current thread (Spring MVC does this for you). For our test, we need + // to add a mock request manually + setupFakeRequest("http://localhost/accounts"); + + HttpEntity result = controller.createAccount(newAccount); + assertThat(result).isNotNull(); + + // See StubAccountManager.nextEntityId - initialized to 3 + assertThat(result.getHeaders().getLocation().toString()).isEqualTo("http://localhost/accounts/3"); + } + + @Test + public void getBeneficiary() { + Beneficiary beneficiary = controller.getBeneficiary(0, "Corgan"); + assertThat(beneficiary).isNotNull(); + assertThat(beneficiary.getEntityId()).isEqualTo(Long.valueOf(1)); + } + + @Test + public void addBeneficiary() { + + // ServletUriComponentsBuilder expects to find the HttpRequest in the + // current thread (Spring MVC does this for you). For our test, we need + // to add a mock request manually + setupFakeRequest("http://localhost/accounts/0/beneficiaries"); + + HttpEntity result = controller.addBeneficiary(0L, "Test2"); + assertThat(result).isNotNull(); + assertThat(result.getHeaders().getLocation().toString()).isEqualTo("http://localhost/accounts/0/beneficiaries/Test2"); + } + + @Test + public void removeBeneficiary() { + controller.removeBeneficiary(0L, "Corgan"); + } + + @Test + public void removeBeneficiaryFail() { + + assertThrows(IllegalArgumentException.class, () -> { + controller.removeBeneficiary(0L, "Fred"); + }, "No such beneficiary 'Fred', " + "IllegalArgumentException expected"); + } + + /** + * Add a mocked up HttpServletRequest to Spring's internal request-context + * holder. Normally the DispatcherServlet does this, but we must do it + * manually to run our test. + * + * @param url + * The URL we are creating the fake request for. + */ + private void setupFakeRequest(String url) { + String requestURI = url.substring(16); // Drop "http://localhost" + + // We can use Spring's convenient mock implementation. Defaults to + // localhost in the URL. Since we only need the URL, we don't need + // to setup anything else in the request. + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestURI); + + // Puts the fake request in the current thread for the + // ServletUriComponentsBuilder to initialize itself from later. + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + +} diff --git a/lab/42-security-rest-solution/build.gradle b/lab/42-security-rest-solution/build.gradle new file mode 100644 index 0000000..72bfa59 --- /dev/null +++ b/lab/42-security-rest-solution/build.gradle @@ -0,0 +1,11 @@ +apply plugin: "org.springframework.boot" + +dependencies { + implementation project(':00-rewards-common') + implementation project(':01-rewards-db') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-test' + developmentOnly 'org.springframework.boot:spring-boot-devtools' +} + diff --git a/lab/42-security-rest-solution/pom.xml b/lab/42-security-rest-solution/pom.xml new file mode 100644 index 0000000..e0ad241 --- /dev/null +++ b/lab/42-security-rest-solution/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + 42-security-rest-solution + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + UTF-8 + accounts.RestWsApplication + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.security + spring-security-test + test + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + io.spring.training.core-spring + 00-rewards-common + + + io.spring.training.core-spring + 01-rewards-db + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/lab/42-security-rest-solution/src/main/java/accounts/RestWsApplication.java b/lab/42-security-rest-solution/src/main/java/accounts/RestWsApplication.java new file mode 100644 index 0000000..8ce5db7 --- /dev/null +++ b/lab/42-security-rest-solution/src/main/java/accounts/RestWsApplication.java @@ -0,0 +1,19 @@ +package accounts; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Import; + +import config.RestSecurityConfig; + +@SpringBootApplication +@Import(RestSecurityConfig.class) +@EntityScan("rewards.internal") +public class RestWsApplication { + + public static void main(String[] args) { + SpringApplication.run(RestWsApplication.class, args); + } + +} diff --git a/lab/42-security-rest-solution/src/main/java/accounts/security/CustomAuthenticationProvider.java b/lab/42-security-rest-solution/src/main/java/accounts/security/CustomAuthenticationProvider.java new file mode 100644 index 0000000..104dc8e --- /dev/null +++ b/lab/42-security-rest-solution/src/main/java/accounts/security/CustomAuthenticationProvider.java @@ -0,0 +1,38 @@ +package accounts.security; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.AuthorityUtils; + +//@Component +public class CustomAuthenticationProvider implements AuthenticationProvider { + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + + String username = authentication.getName(); + String password = authentication.getCredentials().toString(); + + if (!checkCustomAuthenticationSystem(username, password)) { + throw new BadCredentialsException("Bad credentials provided"); + } + + return new UsernamePasswordAuthenticationToken( + username, password, AuthorityUtils.createAuthorityList("ROLE_ADMIN")); + } + + + @Override + public boolean supports(Class authentication) { + return authentication.equals(UsernamePasswordAuthenticationToken.class); + } + + // Use custom authentication system for the verification of the + // passed username and password. (Here we are just faking it.) + private boolean checkCustomAuthenticationSystem(String username, String password) { + return username.equals("spring") && password.equals("spring"); + } +} \ No newline at end of file diff --git a/lab/42-security-rest-solution/src/main/java/accounts/security/CustomUserDetailsService.java b/lab/42-security-rest-solution/src/main/java/accounts/security/CustomUserDetailsService.java new file mode 100644 index 0000000..7169d49 --- /dev/null +++ b/lab/42-security-rest-solution/src/main/java/accounts/security/CustomUserDetailsService.java @@ -0,0 +1,35 @@ +package accounts.security; + +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Primary; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; + +//@Component +@Primary +public class CustomUserDetailsService implements UserDetailsService { + + private final PasswordEncoder passwordEncoder; + + public CustomUserDetailsService(PasswordEncoder passwordEncoder) { + this.passwordEncoder = passwordEncoder; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User.UserBuilder builder = User.builder(); + builder.username(username); + builder.password(passwordEncoder.encode(username)); + + switch (username) { + case "mary" -> builder.roles("USER"); + case "joe" -> builder.roles("USER", "ADMIN"); + default -> throw new UsernameNotFoundException("User not found."); + } + + return builder.build(); + } +} \ No newline at end of file diff --git a/lab/42-security-rest-solution/src/main/java/accounts/services/AccountService.java b/lab/42-security-rest-solution/src/main/java/accounts/services/AccountService.java new file mode 100644 index 0000000..66c8e49 --- /dev/null +++ b/lab/42-security-rest-solution/src/main/java/accounts/services/AccountService.java @@ -0,0 +1,28 @@ +package accounts.services; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class AccountService { + + @PreAuthorize("hasRole('ADMIN') && #username == principal.username") + public List getAuthoritiesForUser(String username) { + + Collection grantedAuthorities + = SecurityContextHolder.getContext() + .getAuthentication() + .getAuthorities(); + + return grantedAuthorities.stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + } + +} diff --git a/lab/42-security-rest-solution/src/main/java/accounts/web/AccountController.java b/lab/42-security-rest-solution/src/main/java/accounts/web/AccountController.java new file mode 100644 index 0000000..0a3fdf4 --- /dev/null +++ b/lab/42-security-rest-solution/src/main/java/accounts/web/AccountController.java @@ -0,0 +1,194 @@ +package accounts.web; + +import accounts.AccountManager; +import accounts.services.AccountService; +import common.money.Percentage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; + +@RestController +public class AccountController { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final AccountManager accountManager; + private final AccountService accountService; + + /** + * Creates a new AccountController with a given account manager. + */ + public AccountController(AccountManager accountManager, + AccountService accountService) { + this.accountManager = accountManager; + this.accountService = accountService; + } + + @GetMapping("/authorities") + public List getAuthoritiesForUser(@RequestParam String username) { + + return accountService.getAuthoritiesForUser(username); + + } + + /** + * Provide a list of all accounts. + */ + @GetMapping(value = "/accounts") + public List accountSummary() { + return accountManager.getAllAccounts(); + } + + /** + * Provide the details of an account with the given id. + */ + @GetMapping(value = "/accounts/{id}") + public Account accountDetails(@PathVariable int id) { + return retrieveAccount(id); + } + + /** + * Creates a new Account, setting its URL as the Location header on the + * response. + */ + @PostMapping(value = "/accounts") + public ResponseEntity createAccount(@RequestBody Account newAccount) { + Account account = accountManager.save(newAccount); + return entityWithLocation(account.getEntityId()); + } + + /** + * Returns the Beneficiary with the given name for the Account with the + * given id. + */ + @GetMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + public Beneficiary getBeneficiary(@PathVariable int accountId, + @PathVariable String beneficiaryName) { + return retrieveAccount(accountId).getBeneficiary(beneficiaryName); + } + + /** + * Adds a Beneficiary with the given name to the Account with the given id, + * setting its URL as the Location header on the response. + */ + @PostMapping(value = "/accounts/{accountId}/beneficiaries") + public ResponseEntity addBeneficiary(@PathVariable long accountId, @RequestBody String beneficiaryName) { + accountManager.addBeneficiary(accountId, beneficiaryName); + return entityWithLocation(beneficiaryName); + } + + /** + * Removes the Beneficiary with the given name from the Account with the + * given id. + */ + @DeleteMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + @ResponseStatus(HttpStatus.NO_CONTENT) // 204 + public void removeBeneficiary(@PathVariable long accountId, @PathVariable String beneficiaryName) { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + Beneficiary deletedBeneficiary = account.getBeneficiary(beneficiaryName); + + HashMap allocationPercentages = new HashMap<>(); + + // If we are removing the only beneficiary or the beneficiary has an + // allocation of zero we don't need to worry. Otherwise, need to share + // out the benefit of the deleted beneficiary amongst all the others + if (account.getBeneficiaries().size() != 1 + && (!deletedBeneficiary.getAllocationPercentage().equals(Percentage.zero()))) { + // This logic is very simplistic, doesn't account for rounding errors + Percentage p = deletedBeneficiary.getAllocationPercentage(); + int remaining = account.getBeneficiaries().size() - 1; + double extra = p.asDouble() / remaining; + + for (Beneficiary beneficiary : account.getBeneficiaries()) { + if (beneficiary != deletedBeneficiary) { + double newValue = beneficiary.getAllocationPercentage().asDouble() + extra; + allocationPercentages.put(beneficiary.getName(), new Percentage(newValue)); + } + } + } + + accountManager.removeBeneficiary(accountId, beneficiaryName, allocationPercentages); + } + + /** + * Maps UnsupportedOperationException to a 501 Not Implemented HTTP status + * code. + */ + @ResponseStatus(HttpStatus.NOT_IMPLEMENTED) + @ExceptionHandler({ UnsupportedOperationException.class }) + public void handleUnabletoReallocate(Exception ex) { + logger.error("Exception is: ", ex); + // just return empty 501 + } + + /** + * Maps IllegalArgumentExceptions to a 404 Not Found HTTP status code. + */ + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(IllegalArgumentException.class) + public void handleNotFound(Exception ex) { + logger.error("Exception is: ", ex); + // return empty 404 + } + + /** + * Maps DataIntegrityViolationException to a 409 Conflict HTTP status code. + */ + @ResponseStatus(HttpStatus.CONFLICT) + @ExceptionHandler({ DataIntegrityViolationException.class }) + public void handleAlreadyExists(Exception ex) { + logger.error("Exception is: ", ex); + // return empty 409 + } + + /** + * Finds the Account with the given id, throwing an IllegalArgumentException + * if there is no such Account. + */ + private Account retrieveAccount(long accountId) throws IllegalArgumentException { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + return account; + } + + /** + * Return a response with the location of the new resource. Its URL is + * assumed to be a child of the URL just received. + * + * Suppose we have just received an incoming URL of, say, + * http://localhost:8080/accounts and resourceId + * is "12345". Then the URL of the new resource will be + * http://localhost:8080/accounts/12345. + * + * @param resourceId + * Is of the new resource. + * @return + */ + private ResponseEntity entityWithLocation(Object resourceId) { + + // Determines URL of child resource based on the full URL of the given + // request, appending the path info with the given resource Identifier + URI location = ServletUriComponentsBuilder.fromCurrentRequestUri().path("/{childId}").buildAndExpand(resourceId) + .toUri(); + + // Return an HttpEntity object - it will be used to build the + // HttpServletResponse + return ResponseEntity.created(location).build(); + } + +} diff --git a/lab/42-security-rest-solution/src/main/java/config/RestSecurityConfig.java b/lab/42-security-rest-solution/src/main/java/config/RestSecurityConfig.java new file mode 100644 index 0000000..b56ba4a --- /dev/null +++ b/lab/42-security-rest-solution/src/main/java/config/RestSecurityConfig.java @@ -0,0 +1,54 @@ +package config; + +import static org.springframework.security.config.Customizer.withDefaults; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableMethodSecurity +public class RestSecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + // @formatter:off + http.authorizeHttpRequests((authz) -> authz + .requestMatchers(HttpMethod.GET, "/accounts/**").hasAnyRole("USER", "ADMIN", "SUPERADMIN") + .requestMatchers(HttpMethod.PUT, "/accounts/**").hasAnyRole("ADMIN", "SUPERADMIN") + .requestMatchers(HttpMethod.POST, "/accounts/**").hasAnyRole("ADMIN", "SUPERADMIN") + .requestMatchers(HttpMethod.DELETE, "/accounts/**").hasAnyRole("SUPERADMIN") + .requestMatchers(HttpMethod.GET, "/authorities").hasAnyRole("USER", "ADMIN", "SUPERADMIN") + .anyRequest().denyAll()) + .httpBasic(withDefaults()) + .csrf(CsrfConfigurer::disable); + // @formatter:on + + return http.build(); + } + + @Bean + public InMemoryUserDetailsManager userDetailsService(PasswordEncoder passwordEncoder) { + + UserDetails user = User.withUsername("user").password(passwordEncoder.encode("user")).roles("USER").build(); + UserDetails admin = User.withUsername("admin").password(passwordEncoder.encode("admin")).roles("USER", "ADMIN").build(); + UserDetails superadmin = User.withUsername("superadmin").password(passwordEncoder.encode("superadmin")).roles("USER", "ADMIN", "SUPERADMIN").build(); + + return new InMemoryUserDetailsManager(user, admin, superadmin); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } +} diff --git a/lab/42-security-rest-solution/src/main/resources/application.properties b/lab/42-security-rest-solution/src/main/resources/application.properties new file mode 100644 index 0000000..e4d9f9b --- /dev/null +++ b/lab/42-security-rest-solution/src/main/resources/application.properties @@ -0,0 +1,4 @@ +# Control how Boot loads data when it starts: +spring.jpa.hibernate.ddl-auto=none +spring.sql.init.schema-locations=classpath:/rewards/testdb/schema.sql +spring.sql.init.data-locations=classpath:/rewards/testdb/data.sql \ No newline at end of file diff --git a/lab/42-security-rest-solution/src/main/resources/static/README.md b/lab/42-security-rest-solution/src/main/resources/static/README.md new file mode 100644 index 0000000..0e64b37 --- /dev/null +++ b/lab/42-security-rest-solution/src/main/resources/static/README.md @@ -0,0 +1,7 @@ +# Spring Boot Resources + +By default, Spring Boot looks for resources in `classpath:static`, which is +this location. However `if src/main/webapp` exists it will look in there +first. + +Spring Boot is also conveniently configured to map '/' to `index.html`. \ No newline at end of file diff --git a/lab/42-security-rest-solution/src/main/resources/static/index.html b/lab/42-security-rest-solution/src/main/resources/static/index.html new file mode 100644 index 0000000..19ec648 --- /dev/null +++ b/lab/42-security-rest-solution/src/main/resources/static/index.html @@ -0,0 +1,50 @@ + + + + + + + + + + + rest-ws-solution: RESTful web services with Spring Web MVC + + + + +
+
+ +
+ +
+ +

rest-ws-solution: RESTful applications with Spring MVC

+ +

This application is intended for programmatic clients, not browsers

+ +
+
+ + + diff --git a/lab/42-security-rest-solution/src/test/java/accounts/client/AccountClientTests.java b/lab/42-security-rest-solution/src/test/java/accounts/client/AccountClientTests.java new file mode 100644 index 0000000..eb83390 --- /dev/null +++ b/lab/42-security-rest-solution/src/test/java/accounts/client/AccountClientTests.java @@ -0,0 +1,129 @@ +package accounts.client; + +import accounts.RestWsApplication; +import common.money.Percentage; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.util.Random; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest(classes = {RestWsApplication.class}, + webEnvironment = WebEnvironment.RANDOM_PORT) +public class AccountClientTests { + + @Autowired + private TestRestTemplate restTemplate; + + private final Random random = new Random(); + + @Test + public void listAccounts_using_invalid_user_should_return_401() { + ResponseEntity responseEntity + = restTemplate.withBasicAuth("invalid", "invalid") + .getForEntity("/accounts", String.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + public void listAccounts_using_valid_user_should_succeed() { + String url = "/accounts"; + // we have to use Account[] instead of List, or Jackson won't know what type to unmarshal to + ResponseEntity responseEntity + = restTemplate.withBasicAuth("user", "user") + .getForEntity(url, Account[].class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + Account[] accounts = responseEntity.getBody(); + assertThat(accounts.length >= 21).withFailMessage("Expected 21 accounts, but found " + accounts.length).isTrue(); + assertThat(accounts[0].getName()).isEqualTo("Keith and Keri Donald"); + assertThat(accounts[0].getBeneficiaries().size()).isEqualTo(2); + assertThat(accounts[0].getBeneficiary("Annabelle").getAllocationPercentage()).isEqualTo(Percentage.valueOf("50%")); + } + + @Test + public void listAccounts_using_valid_admin_should_succeed() { + String url = "/accounts"; + // we have to use Account[] instead of List, or Jackson won't know what type to unmarshal to + ResponseEntity responseEntity + = restTemplate.withBasicAuth("admin", "admin") + .getForEntity(url, Account[].class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + Account[] accounts = responseEntity.getBody(); + assertThat(accounts.length >= 21).withFailMessage("Expected 21 accounts, but found " + accounts.length).isTrue(); + assertThat(accounts[0].getName()).isEqualTo("Keith and Keri Donald"); + assertThat(accounts[0].getBeneficiaries().size()).isEqualTo(2); + assertThat(accounts[0].getBeneficiary("Annabelle").getAllocationPercentage()).isEqualTo(Percentage.valueOf("50%")); + } + + @Test + public void getAccount_using_valid_user_should_succeed() { + String url = "/accounts/{accountId}"; + ResponseEntity responseEntity + = restTemplate.withBasicAuth("user", "user") + .getForEntity(url, Account.class, 0); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + Account account = responseEntity.getBody(); + assertThat(account.getName()).isEqualTo("Keith and Keri Donald"); + assertThat(account.getBeneficiaries().size()).isEqualTo(2); + assertThat(account.getBeneficiary("Annabelle").getAllocationPercentage()).isEqualTo(Percentage.valueOf("50%")); + } + + @Test + public void createAccount_using_admin_should_return_201() { + String url = "/accounts"; + // use a unique number to avoid conflicts + String number = "12345%4d".formatted(random.nextInt(10000)); + Account account = new Account(number, "John Doe"); + account.addBeneficiary("Jane Doe"); + ResponseEntity responseEntity + = restTemplate.withBasicAuth("admin", "admin") + .postForEntity(url, account, Void.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + + @Test + public void createAccount_using_user_should_return_403() { + String url = "/accounts"; + // use a unique number to avoid conflicts + String number = "12345%4d".formatted(random.nextInt(10000)); + Account account = new Account(number, "John Doe"); + account.addBeneficiary("Jane Doe"); + ResponseEntity responseEntity + = restTemplate.withBasicAuth("user", "user") + .postForEntity(url, account, Void.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + public void addAndDeleteBeneficiary_using_superadmin_should_succeed() { + // perform both add and delete to avoid issues with side effects + String addUrl = "/accounts/{accountId}/beneficiaries"; + URI newBeneficiaryLocation + = restTemplate.withBasicAuth("superadmin", "superadmin") + .postForLocation(addUrl, "David", 1); + + Beneficiary newBeneficiary + = restTemplate.withBasicAuth("superadmin", "superadmin") + .getForObject(newBeneficiaryLocation, Beneficiary.class); + assertThat(newBeneficiary.getName()).isEqualTo("David"); + + restTemplate.withBasicAuth("superadmin", "superadmin").delete(newBeneficiaryLocation); + + // use exchange method to receive a 404 response + ResponseEntity response = + restTemplate.withBasicAuth("superadmin", "superadmin") + .getForEntity(newBeneficiaryLocation, Beneficiary.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + +} \ No newline at end of file diff --git a/lab/42-security-rest-solution/src/test/java/accounts/services/AccountServiceMethodSecurityTest.java b/lab/42-security-rest-solution/src/test/java/accounts/services/AccountServiceMethodSecurityTest.java new file mode 100644 index 0000000..e194cd1 --- /dev/null +++ b/lab/42-security-rest-solution/src/test/java/accounts/services/AccountServiceMethodSecurityTest.java @@ -0,0 +1,50 @@ +package accounts.services; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class AccountServiceMethodSecurityTest { + + @Autowired + private TestRestTemplate restTemplate; + + @Test + void getAuthoritiesForUser_should_return_403_for_user() { + + ResponseEntity responseEntity = restTemplate.withBasicAuth("user", "user") + .getForEntity("/authorities?username=user", String.class); + + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void getAuthoritiesForUser_should_return_authorities_for_admin() { + + String[] authorities = restTemplate.withBasicAuth("admin", "admin") + .getForObject("/authorities?username=admin", String[].class); + assertThat(authorities.length).isEqualTo(2); + assertThat(authorities.toString().contains("ROLE_ADMIN")); + assertThat(authorities.toString().contains("ROLE_USER")); + + } + + @Test + void getAuthoritiesForUser_should_return_authorities_for_superadmin() { + + String[] authorities = restTemplate.withBasicAuth("superadmin", "superadmin") + .getForObject("/authorities?username=superadmin", String[].class); + assertThat(authorities.length).isEqualTo(3); + assertThat(authorities.toString().contains("ROLE_SUPERADMIN")); + assertThat(authorities.toString().contains("ROLE_ADMIN")); + assertThat(authorities.toString().contains("ROLE_USER")); + } + +} \ No newline at end of file diff --git a/lab/42-security-rest-solution/src/test/java/accounts/web/AccountControllerCustomAuthenticationProviderTests.java b/lab/42-security-rest-solution/src/test/java/accounts/web/AccountControllerCustomAuthenticationProviderTests.java new file mode 100644 index 0000000..675e431 --- /dev/null +++ b/lab/42-security-rest-solution/src/test/java/accounts/web/AccountControllerCustomAuthenticationProviderTests.java @@ -0,0 +1,58 @@ +package accounts.web; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; + +import accounts.AccountManager; +import accounts.RestWsApplication; +import accounts.security.CustomAuthenticationProvider; +import accounts.services.AccountService; +import config.RestSecurityConfig; +import rewards.internal.account.Account; + + +@WebMvcTest(AccountController.class) +@ContextConfiguration(classes = {RestWsApplication.class, RestSecurityConfig.class, CustomAuthenticationProvider.class}) +public class AccountControllerCustomAuthenticationProviderTests { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AccountManager accountManager; + + @MockBean + private AccountService accountService; + + @Test + public void accountDetails_with_spring_credentials_should_return_200() throws Exception { + + // arrange + given(accountManager.getAccount(0L)).willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0").with(httpBasic("spring", "spring"))) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")).andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(0L); + + } + +} + diff --git a/lab/42-security-rest-solution/src/test/java/accounts/web/AccountControllerCustomUserDetailsServiceTests.java b/lab/42-security-rest-solution/src/test/java/accounts/web/AccountControllerCustomUserDetailsServiceTests.java new file mode 100644 index 0000000..70c5a25 --- /dev/null +++ b/lab/42-security-rest-solution/src/test/java/accounts/web/AccountControllerCustomUserDetailsServiceTests.java @@ -0,0 +1,74 @@ +package accounts.web; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; + +import accounts.AccountManager; +import accounts.RestWsApplication; +import accounts.security.CustomUserDetailsService; +import accounts.services.AccountService; +import config.RestSecurityConfig; +import rewards.internal.account.Account; + +@WebMvcTest(AccountController.class) +@ContextConfiguration(classes = {RestWsApplication.class, RestSecurityConfig.class, CustomUserDetailsService.class}) +public class AccountControllerCustomUserDetailsServiceTests { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AccountManager accountManager; + + @MockBean + private AccountService accountService; + + @Test + @WithUserDetails("joe") + public void accountDetails_with_joe_credentials_should_return_200() throws Exception { + + // arrange + given(accountManager.getAccount(0L)).willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0")).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")).andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(0L); + + } + + @Test + @WithUserDetails("mary") + public void accountDetails_with_mary_credentials_should_return_200() throws Exception { + + // arrange + given(accountManager.getAccount(0L)).willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0")).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")).andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(0L); + + } + +} + diff --git a/lab/42-security-rest-solution/src/test/java/accounts/web/AccountControllerTests.java b/lab/42-security-rest-solution/src/test/java/accounts/web/AccountControllerTests.java new file mode 100644 index 0000000..eb9319f --- /dev/null +++ b/lab/42-security-rest-solution/src/test/java/accounts/web/AccountControllerTests.java @@ -0,0 +1,267 @@ +package accounts.web; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import accounts.AccountManager; +import accounts.RestWsApplication; +import accounts.services.AccountService; +import common.money.Percentage; +import config.RestSecurityConfig; +import rewards.internal.account.Account; + +@WebMvcTest(AccountController.class) +@ContextConfiguration(classes = {RestWsApplication.class, RestSecurityConfig.class}) +public class AccountControllerTests { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AccountManager accountManager; + + @MockBean + private AccountService accountService; + + @Test + @WithMockUser(roles = {"INVALID"}) + void accountSummary_with_invalid_role_should_return_403() throws Exception { + + mockMvc.perform(get("/accounts")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser( roles = {"USER"}) + public void accountDetails_with_USER_role_should_return_200() throws Exception { + + // arrange + given(accountManager.getAccount(0L)).willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0")).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")).andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(0L); + + } + + @Test + @WithMockUser(username = "user", password = "user") + public void accountDetails_with_user_credentials_should_return_200() throws Exception { + + // arrange + given(accountManager.getAccount(0L)).willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0")).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")) + .andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(0L); + + } + + @Test + @WithMockUser(username = "admin", password = "admin") + public void accountDetails_with_admin_credentials_should_return_200() throws Exception { + + // arrange + given(accountManager.getAccount(0L)).willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0")).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")).andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(0L); + + } + + @Test + @WithMockUser(username = "superadmin", password = "superadmin") + public void accountDetails_with_superadmin_credentials_should_return_200() throws Exception { + + // arrange + given(accountManager.getAccount(0L)).willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0")).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")).andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(0L); + + } + + @Test + @WithMockUser(roles = {"USER"}) + public void accountDetailsFail_test_with_USER_role_should_proceed_successfully() throws Exception { + + given(accountManager.getAccount(any(Long.class))) + .willThrow(new IllegalArgumentException("No such account with id " + 0L)); + + mockMvc.perform(get("/accounts/0")).andExpect(status().isNotFound()); + + verify(accountManager).getAccount(any(Long.class)); + + } + + @Test + @WithMockUser(roles = {"ADMIN"}) + public void accountSummary_with_ADMIN_role_should_return_200() throws Exception { + + List testAccounts = List.of(new Account("123456789", "John Doe")); + given(accountManager.getAllAccounts()).willReturn(testAccounts); + + mockMvc.perform(get("/accounts")).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$..number").value("123456789")).andExpect(jsonPath("$..name").value("John Doe")); + + verify(accountManager).getAllAccounts(); + + } + + @Test + @WithMockUser(roles = {"ADMIN", "SUPERADMIN"}) + public void createAccount_with_ADMIN_or_SUPERADMIN_role_should_return_201() throws Exception { + + Account testAccount = new Account("1234512345", "Mary Jones"); + testAccount.setEntityId(21L); + given(accountManager.save(any(Account.class))).willReturn(testAccount); + + mockMvc.perform(post("/accounts").contentType(MediaType.APPLICATION_JSON) + .content(asJsonString(testAccount)).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "http://localhost/accounts/21")); + + verify(accountManager).save(any(Account.class)); + + } + + @Test + @WithMockUser(roles = {"USER"}) + public void createAccount_with_USER_role_should_return_403() throws Exception { + + Account testAccount = new Account("1234512345", "Mary Jones"); + testAccount.setEntityId(21L); + + mockMvc.perform(post("/accounts").contentType(MediaType.APPLICATION_JSON) + .content(asJsonString(testAccount)).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + + } + + @Test + @WithMockUser(roles = {"SUPERADMIN"}) + public void getBeneficiary_with_SUPERADMIN_role_should_return_200() throws Exception { + + Account account = new Account("1234567890", "John Doe"); + account.addBeneficiary("Corgan", new Percentage(0.1)); + given(accountManager.getAccount(0L)).willReturn(account); + + mockMvc.perform(get("/accounts/{accountId}/beneficiaries/{beneficiaryName}", 0L, "Corgan")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("Corgan")) + .andExpect(jsonPath("allocationPercentage").value("0.1")); + + verify(accountManager).getAccount(0L); + } + + @Test + @WithMockUser(roles = {"ADMIN", "SUPERADMIN"}) + public void addBeneficiary_with_ADMIN_or_SUPERADMIN_role_should_return_201() throws Exception { + + mockMvc.perform(post("/accounts/{entityId}/beneficiaries", 0L).content("Kate")) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "http://localhost/accounts/0/beneficiaries/Kate")); + } + + @Test + @WithMockUser(roles = {"USER"}) + public void addBeneficiary_with_USER_role_should_return_403() throws Exception { + + mockMvc.perform(post("/accounts/{entityId}/beneficiaries", 0L).content("Kate")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(roles = {"SUPERADMIN"}) + public void removeBeneficiary_with_SUPERADMIN_role_should_return_204() throws Exception { + + Account account = new Account("1234567890", "John Doe"); + account.addBeneficiary("Corgan", new Percentage(0.1)); + given(accountManager.getAccount(0L)).willReturn(account); + + mockMvc.perform(delete("/accounts/{entityId}/beneficiaries/{name}", 0L, "Corgan")) + .andExpect(status().isNoContent()); + + verify(accountManager).getAccount(0L); + + } + + @Test + @WithMockUser(roles = {"USER", "ADMIN"}) + public void removeBeneficiary_with_USER_or_ADMIN_role_should_return_403() throws Exception { + + Account account = new Account("1234567890", "John Doe"); + account.addBeneficiary("Corgan", new Percentage(0.1)); + given(accountManager.getAccount(0L)).willReturn(account); + + mockMvc.perform(delete("/accounts/{entityId}/beneficiaries/{name}", 0L, "Corgan")) + .andExpect(status().isForbidden()); + + } + + @Test + @WithMockUser(roles = {"SUPERADMIN"}) + public void removeBeneficiaryFail_test_with_SUPERADMIN_role_should_proceed_successfully() throws Exception { + Account account = new Account("1234567890", "John Doe"); + given(accountManager.getAccount(0L)).willReturn(account); + + mockMvc.perform(delete("/accounts/{entityId}/beneficiaries/{name}", 0L, "Noname")) + .andExpect(status().isNotFound()); + + verify(accountManager).getAccount(0L); + } + + protected static String asJsonString(final Object obj) { + try { + final ObjectMapper mapper = new ObjectMapper(); + return mapper.writeValueAsString(obj); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + +} + diff --git a/lab/42-security-rest/build.gradle b/lab/42-security-rest/build.gradle new file mode 100644 index 0000000..cb06d15 --- /dev/null +++ b/lab/42-security-rest/build.gradle @@ -0,0 +1,14 @@ +apply plugin: "org.springframework.boot" + +dependencies { + implementation project(':00-rewards-common') + implementation project(':01-rewards-db') + implementation 'org.springframework.boot:spring-boot-starter-web' + + // TO-DO-01: Verify the presence of Spring Boot Security dependencies + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-test' + + developmentOnly 'org.springframework.boot:spring-boot-devtools' +} + diff --git a/lab/42-security-rest/pom.xml b/lab/42-security-rest/pom.xml new file mode 100644 index 0000000..22b7a40 --- /dev/null +++ b/lab/42-security-rest/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + 42-security-rest + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + UTF-8 + accounts.RestWsApplication + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.security + spring-security-test + test + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + io.spring.training.core-spring + 00-rewards-common + + + io.spring.training.core-spring + 01-rewards-db + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/lab/42-security-rest/src/main/java/accounts/RestWsApplication.java b/lab/42-security-rest/src/main/java/accounts/RestWsApplication.java new file mode 100644 index 0000000..0a55e3a --- /dev/null +++ b/lab/42-security-rest/src/main/java/accounts/RestWsApplication.java @@ -0,0 +1,96 @@ +package accounts; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; + +// TODO-00: In this lab, you are going to exercise the following: +// - Observing the default security behavior +// - Configuring authorization based on roles +// - Configuring authentication using in-memory storage +// - Configuring method-level security +// - Adding custom UserDetailsService +// - Adding custom AuthenticationProvider +// - Writing test code for security + +// TODO-01: Verify the presence of Spring security dependencies +// - See TO-DO-01 in the pom.xml for Maven or build.gradle for Gradle + +// TODO-02a: Observe the default security behaviour of the Spring +// Boot application using a browser +// - Run this application +// - Using a browser, access "http://localhost:8080/accounts" +// and observe that a login page gets displayed +// - Enter "user" in the Username field and Spring Boot generated +// password into the Password field and verify that the accounts +// get displayed +// (If the browser keeps displaying the login page, use Chrome +// Incognito browser.) +// - Access "http://localhost:8080/logout" and click "Log out" button + +// TODO-02b: Observe the default security behaviour of the Spring +// Boot application using "curl" (or "Postman") +// - Open a terminal window (if you are using "curl") +// - Run "curl -i localhost:8080/accounts" and observe 401 response +// - Run "curl -i -u user: localhost:8080/accounts" +// and observe a successful response + +@SpringBootApplication +//TODO-03: Import security configuration class +//- Uncomment the line below and go to RestSecurityConfig class +//@Import(RestSecurityConfig.class) +@EntityScan("rewards.internal") +public class RestWsApplication { + + public static void main(String[] args) { + SpringApplication.run(RestWsApplication.class, args); + } + +} + +// TODO-11: Test the method security using browser or curl +// - Re-run this application +// - Using Chrome Incognito browser, access +// http://localhost:8080/authorities?username=user +// - Enter "user"/"user" and verify that 403 failure occurs +// - If you want to use "curl", use +// curl -i -u user:user http://localhost:8080/authorities?username=user +// +// - Close the Chrome Incognito browser and start a new one +// - Access http://localhost:8080/authorities?username=admin +// - Enter "admin"/"admin" and verify that the roles are displayed successfully +// - If you want to use "curl", use +// curl -i -u admin:admin http://localhost:8080/authorities?username=admin +// +// - Close the Chrome Incognito browser and start a new one +// - Access http://localhost:8080/authorities?username=superadmin +// - Enter "superadmin"/"superadmin" and verify that the roles are displayed successfully +// - If you want to use "curl", use +// curl -i -u superadmin:superadmin http://localhost:8080/authorities?username=superadmin + +// ------------------------------------------------ + +// TODO-15 (Optional): Verify that the newly added custom UserDetailsService works +// - Re-run this application +// - Using Chrome Incognito browser, access +// http://localhost:8080/accounts/0 +// - Enter "mary"/"mary" and verify accounts data gets displayed +// - If you want to use "curl", use +// curl -i -u mary:mary http://localhost:8080/accounts/0 +// +// - Close the Chrome Incognito browser and start a new one +// - Using Chrome Incognito browser, access +// http://localhost:8080/accounts/0 +// - Enter "joe"/"joe" and verify accounts data gets displayed +// - If you want to use "curl", use +// curl -i -u joe:joe http://localhost:8080/accounts/0 + +// ------------------------------------------------ + +// TODO-19 (Optional): Verify that the newly added custom AuthenticationProvider works +// - Re-run this application +// - Using Chrome Incognito browser, access +// http://localhost:8080/accounts/0 +// - Enter "spring"/"spring" and verify accounts data +// - If you want to use "curl", use +// curl -i -u spring:spring http://localhost:8080/accounts/0 \ No newline at end of file diff --git a/lab/42-security-rest/src/main/java/accounts/security/CustomAuthenticationProvider.java b/lab/42-security-rest/src/main/java/accounts/security/CustomAuthenticationProvider.java new file mode 100644 index 0000000..9492179 --- /dev/null +++ b/lab/42-security-rest/src/main/java/accounts/security/CustomAuthenticationProvider.java @@ -0,0 +1,47 @@ +package accounts.security; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.AuthorityUtils; + +//TODO-17 (Optional): Create custom AuthenticationProvider +//- Note that it needs to implement AuthenticationProvider interface +//- Uncomment the commented code fragment below so that this custom +//AuthenticationProvider handles a user with the following credentials +//- "spring"/"spring" with "ROLE_ADMIN" role + +//TODO-18a (Optional): Add authentication based upon the custom AuthenticationProvider +//- Annotate the class with @Component to make it a Spring manager bean + +public class CustomAuthenticationProvider implements AuthenticationProvider { + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + +// String username = authentication.getName(); +// String password = authentication.getCredentials().toString(); +// +// if (!checkCustomAuthenticationSystem(username, password)) { +// throw new BadCredentialsException("Bad credentials provided"); +// } +// +// return new UsernamePasswordAuthenticationToken( +// username, password, AuthorityUtils.createAuthorityList("ROLE_ADMIN")); + + return null; // remove this line + } + + @Override + public boolean supports(Class authentication) { + return authentication.equals(UsernamePasswordAuthenticationToken.class); + } + + // Use custom authentication system for the verification of the + // passed username and password. (Here we are just faking it.) + private boolean checkCustomAuthenticationSystem(String username, String password) { + return username.equals("spring") && password.equals("spring"); + } +} diff --git a/lab/42-security-rest/src/main/java/accounts/security/CustomUserDetailsService.java b/lab/42-security-rest/src/main/java/accounts/security/CustomUserDetailsService.java new file mode 100644 index 0000000..bcada80 --- /dev/null +++ b/lab/42-security-rest/src/main/java/accounts/security/CustomUserDetailsService.java @@ -0,0 +1,52 @@ +package accounts.security; + +import org.springframework.context.annotation.Primary; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; + +//Optional exercise - Do the remaining steps only if you have extra time +//TODO-13 (Optional): Create custom UserDetailsService +//- Note that it needs to implement loadUserByUsername method +//of the UserDetailsService interface +//- Uncomment the commented code fragment below so that this custom +//UserDetailsService maintains UserDetails of two users: +//- "mary"/"mary" with "USER" role and +//- "joe"/"joe" with "USER" and "ADMIN" roles + +//TODO-14a (Optional): Add authentication based upon the custom UserDetailsService +//- Annotate the class with @Component to make it a Spring manager bean + +//TODO-18b (Optional): Remove the CustomUserDetailsService definition +// - Comment the @Component annotation added in a previous task + +@Primary +public class CustomUserDetailsService implements UserDetailsService { + + private final PasswordEncoder passwordEncoder; + + public CustomUserDetailsService(PasswordEncoder passwordEncoder) { + this.passwordEncoder = passwordEncoder; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User.UserBuilder builder = User.builder(); +// builder.username(username); +// builder.password(passwordEncoder.encode(username)); +// switch (username) { +// case "mary": +// builder.roles("USER"); +// break; +// case "joe": +// builder.roles("USER", "ADMIN"); +// break; +// default: +// throw new UsernameNotFoundException("User not found."); +// } + + return builder.build(); + } +} diff --git a/lab/42-security-rest/src/main/java/accounts/services/AccountService.java b/lab/42-security-rest/src/main/java/accounts/services/AccountService.java new file mode 100644 index 0000000..89fccfc --- /dev/null +++ b/lab/42-security-rest/src/main/java/accounts/services/AccountService.java @@ -0,0 +1,46 @@ +package accounts.services; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class AccountService { + + // TODO-09: Add method security annotation to a method + // - Uncomment and complete PreAuthorize annotation below + // so that the method is permitted to be invoked only + // when both of following two run-time conditions are met: + // (Use SpEL to specify these conditions.) + // + // (a) the logged-in user belongs to "ADMIN" role + // (b) the value of the "username" argument matches + // the value of the logged-in principal's + // username, which can be accessed as + // principal.username or authentication.name. + // + //@PreAuthorize(/* Add code here */) + public List getAuthoritiesForUser(String username) { + + // TODO-08: Retrieve authorities (roles) for the logged-in user + // (This is probably not a typical business logic you will + // have in a service layer. This is mainly to show + // how SecurityContext object is maintained in the local + // thread, which can be accessed via SecurityContextHolder) + // - Replace null below with proper code - use SecurityContextHolder + // - Restart the application (or let Spring Boot Devtools to restart the app) + // - Using Chrome Incognito browser or "curl", access + // http://localhost:8080/authorities?username= + // - Verify that roles of the logged-in user get displayed + Collection grantedAuthorities + = null; // Modify this line + + return grantedAuthorities.stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + } + +} diff --git a/lab/42-security-rest/src/main/java/accounts/web/AccountController.java b/lab/42-security-rest/src/main/java/accounts/web/AccountController.java new file mode 100644 index 0000000..0a3fdf4 --- /dev/null +++ b/lab/42-security-rest/src/main/java/accounts/web/AccountController.java @@ -0,0 +1,194 @@ +package accounts.web; + +import accounts.AccountManager; +import accounts.services.AccountService; +import common.money.Percentage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; + +@RestController +public class AccountController { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final AccountManager accountManager; + private final AccountService accountService; + + /** + * Creates a new AccountController with a given account manager. + */ + public AccountController(AccountManager accountManager, + AccountService accountService) { + this.accountManager = accountManager; + this.accountService = accountService; + } + + @GetMapping("/authorities") + public List getAuthoritiesForUser(@RequestParam String username) { + + return accountService.getAuthoritiesForUser(username); + + } + + /** + * Provide a list of all accounts. + */ + @GetMapping(value = "/accounts") + public List accountSummary() { + return accountManager.getAllAccounts(); + } + + /** + * Provide the details of an account with the given id. + */ + @GetMapping(value = "/accounts/{id}") + public Account accountDetails(@PathVariable int id) { + return retrieveAccount(id); + } + + /** + * Creates a new Account, setting its URL as the Location header on the + * response. + */ + @PostMapping(value = "/accounts") + public ResponseEntity createAccount(@RequestBody Account newAccount) { + Account account = accountManager.save(newAccount); + return entityWithLocation(account.getEntityId()); + } + + /** + * Returns the Beneficiary with the given name for the Account with the + * given id. + */ + @GetMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + public Beneficiary getBeneficiary(@PathVariable int accountId, + @PathVariable String beneficiaryName) { + return retrieveAccount(accountId).getBeneficiary(beneficiaryName); + } + + /** + * Adds a Beneficiary with the given name to the Account with the given id, + * setting its URL as the Location header on the response. + */ + @PostMapping(value = "/accounts/{accountId}/beneficiaries") + public ResponseEntity addBeneficiary(@PathVariable long accountId, @RequestBody String beneficiaryName) { + accountManager.addBeneficiary(accountId, beneficiaryName); + return entityWithLocation(beneficiaryName); + } + + /** + * Removes the Beneficiary with the given name from the Account with the + * given id. + */ + @DeleteMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + @ResponseStatus(HttpStatus.NO_CONTENT) // 204 + public void removeBeneficiary(@PathVariable long accountId, @PathVariable String beneficiaryName) { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + Beneficiary deletedBeneficiary = account.getBeneficiary(beneficiaryName); + + HashMap allocationPercentages = new HashMap<>(); + + // If we are removing the only beneficiary or the beneficiary has an + // allocation of zero we don't need to worry. Otherwise, need to share + // out the benefit of the deleted beneficiary amongst all the others + if (account.getBeneficiaries().size() != 1 + && (!deletedBeneficiary.getAllocationPercentage().equals(Percentage.zero()))) { + // This logic is very simplistic, doesn't account for rounding errors + Percentage p = deletedBeneficiary.getAllocationPercentage(); + int remaining = account.getBeneficiaries().size() - 1; + double extra = p.asDouble() / remaining; + + for (Beneficiary beneficiary : account.getBeneficiaries()) { + if (beneficiary != deletedBeneficiary) { + double newValue = beneficiary.getAllocationPercentage().asDouble() + extra; + allocationPercentages.put(beneficiary.getName(), new Percentage(newValue)); + } + } + } + + accountManager.removeBeneficiary(accountId, beneficiaryName, allocationPercentages); + } + + /** + * Maps UnsupportedOperationException to a 501 Not Implemented HTTP status + * code. + */ + @ResponseStatus(HttpStatus.NOT_IMPLEMENTED) + @ExceptionHandler({ UnsupportedOperationException.class }) + public void handleUnabletoReallocate(Exception ex) { + logger.error("Exception is: ", ex); + // just return empty 501 + } + + /** + * Maps IllegalArgumentExceptions to a 404 Not Found HTTP status code. + */ + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(IllegalArgumentException.class) + public void handleNotFound(Exception ex) { + logger.error("Exception is: ", ex); + // return empty 404 + } + + /** + * Maps DataIntegrityViolationException to a 409 Conflict HTTP status code. + */ + @ResponseStatus(HttpStatus.CONFLICT) + @ExceptionHandler({ DataIntegrityViolationException.class }) + public void handleAlreadyExists(Exception ex) { + logger.error("Exception is: ", ex); + // return empty 409 + } + + /** + * Finds the Account with the given id, throwing an IllegalArgumentException + * if there is no such Account. + */ + private Account retrieveAccount(long accountId) throws IllegalArgumentException { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + return account; + } + + /** + * Return a response with the location of the new resource. Its URL is + * assumed to be a child of the URL just received. + * + * Suppose we have just received an incoming URL of, say, + * http://localhost:8080/accounts and resourceId + * is "12345". Then the URL of the new resource will be + * http://localhost:8080/accounts/12345. + * + * @param resourceId + * Is of the new resource. + * @return + */ + private ResponseEntity entityWithLocation(Object resourceId) { + + // Determines URL of child resource based on the full URL of the given + // request, appending the path info with the given resource Identifier + URI location = ServletUriComponentsBuilder.fromCurrentRequestUri().path("/{childId}").buildAndExpand(resourceId) + .toUri(); + + // Return an HttpEntity object - it will be used to build the + // HttpServletResponse + return ResponseEntity.created(location).build(); + } + +} diff --git a/lab/42-security-rest/src/main/java/config/RestSecurityConfig.java b/lab/42-security-rest/src/main/java/config/RestSecurityConfig.java new file mode 100644 index 0000000..c394c0f --- /dev/null +++ b/lab/42-security-rest/src/main/java/config/RestSecurityConfig.java @@ -0,0 +1,67 @@ +package config; + +import static org.springframework.security.config.Customizer.withDefaults; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +// TODO-10: Enable method security +// - Add @EnableMethodSecurity annotation to this class + +@Configuration +public class RestSecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + // @formatter:off + http.authorizeHttpRequests((authz) -> authz + // TODO-04: Configure authorization using requestMatchers method + // - Allow DELETE on the /accounts resource (or any sub-resource) + // for "SUPERADMIN" role only + // - Allow POST or PUT on the /accounts resource (or any sub-resource) + // for "ADMIN" or "SUPERADMIN" role only + // - Allow GET on the /accounts resource (or any sub-resource) + // for all roles - "USER", "ADMIN", "SUPERADMIN" + // - Allow GET on the /authorities resource + // for all roles - "USER", "ADMIN", "SUPERADMIN" + + // Deny any request that doesn't match any authorization rule + .anyRequest().denyAll()) + .httpBasic(withDefaults()) + .csrf(CsrfConfigurer::disable); + // @formatter:on + + return http.build(); + } + + // TODO-14b (Optional): Remove the InMemoryUserDetailsManager definition + // - Comment the @Bean annotation below + + @Bean + public InMemoryUserDetailsManager userDetailsService(PasswordEncoder passwordEncoder) { + + // TODO-05: Add three users with corresponding roles: + // - "user"/"user" with "USER" role (example code is provided below) + // - "admin"/"admin" with "USER" and "ADMIN" roles + // - "superadmin"/"superadmin" with "USER", "ADMIN", and "SUPERADMIN" roles + // (Make sure to store the password in encoded form.) + // - pass all users in the InMemoryUserDetailsManager constructor + UserDetails user = User.withUsername("user").password(passwordEncoder.encode("user")).roles("USER").build(); + + return new InMemoryUserDetailsManager(user /* Add new users comma-separated here */); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } +} diff --git a/lab/42-security-rest/src/main/resources/application.properties b/lab/42-security-rest/src/main/resources/application.properties new file mode 100644 index 0000000..e4d9f9b --- /dev/null +++ b/lab/42-security-rest/src/main/resources/application.properties @@ -0,0 +1,4 @@ +# Control how Boot loads data when it starts: +spring.jpa.hibernate.ddl-auto=none +spring.sql.init.schema-locations=classpath:/rewards/testdb/schema.sql +spring.sql.init.data-locations=classpath:/rewards/testdb/data.sql \ No newline at end of file diff --git a/lab/42-security-rest/src/main/resources/static/README.md b/lab/42-security-rest/src/main/resources/static/README.md new file mode 100644 index 0000000..0e64b37 --- /dev/null +++ b/lab/42-security-rest/src/main/resources/static/README.md @@ -0,0 +1,7 @@ +# Spring Boot Resources + +By default, Spring Boot looks for resources in `classpath:static`, which is +this location. However `if src/main/webapp` exists it will look in there +first. + +Spring Boot is also conveniently configured to map '/' to `index.html`. \ No newline at end of file diff --git a/lab/42-security-rest/src/main/resources/static/index.html b/lab/42-security-rest/src/main/resources/static/index.html new file mode 100644 index 0000000..19ec648 --- /dev/null +++ b/lab/42-security-rest/src/main/resources/static/index.html @@ -0,0 +1,50 @@ + + + + + + + + + + + rest-ws-solution: RESTful web services with Spring Web MVC + + + + +
+
+ +
+ +
+ +

rest-ws-solution: RESTful applications with Spring MVC

+ +

This application is intended for programmatic clients, not browsers

+ +
+
+ + + diff --git a/lab/42-security-rest/src/test/java/accounts/client/AccountClientTests.java b/lab/42-security-rest/src/test/java/accounts/client/AccountClientTests.java new file mode 100644 index 0000000..f718948 --- /dev/null +++ b/lab/42-security-rest/src/test/java/accounts/client/AccountClientTests.java @@ -0,0 +1,139 @@ +package accounts.client; + +import accounts.RestWsApplication; +import common.money.Percentage; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.util.Random; + +import static org.assertj.core.api.Assertions.*; + +// TODO-07a: Perform security testing against a running server +// - Take some time to understand what each test is for +// - Remove @Disabled annotation from each test and run it +// - Make sure all tests pass + +@SpringBootTest(classes = {RestWsApplication.class}, + webEnvironment = WebEnvironment.RANDOM_PORT) +public class AccountClientTests { + + @Autowired + private TestRestTemplate restTemplate; + + private final Random random = new Random(); + + @Test + @Disabled + public void listAccounts_using_invalid_user_should_return_401() { + ResponseEntity responseEntity + = restTemplate.withBasicAuth("invalid", "invalid") + .getForEntity("/accounts", String.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @Disabled + public void listAccounts_using_valid_user_should_succeed() { + String url = "/accounts"; + // we have to use Account[] instead of List, or Jackson won't know what type to unmarshal to + ResponseEntity responseEntity + = restTemplate.withBasicAuth("user", "user") + .getForEntity(url, Account[].class); + Account[] accounts = responseEntity.getBody(); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(accounts.length >= 21).withFailMessage("Expected 21 accounts, but found " + accounts.length).isTrue(); + assertThat(accounts[0].getName()).isEqualTo("Keith and Keri Donald"); + assertThat(accounts[0].getBeneficiaries().size()).isEqualTo(2); + assertThat(accounts[0].getBeneficiary("Annabelle").getAllocationPercentage()).isEqualTo(Percentage.valueOf("50%")); + } + + @Test + @Disabled + public void listAccounts_using_valid_admin_should_succeed() { + String url = "/accounts"; + // we have to use Account[] instead of List, or Jackson won't know what type to unmarshal to + ResponseEntity responseEntity + = restTemplate.withBasicAuth("admin", "admin") + .getForEntity(url, Account[].class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + Account[] accounts = responseEntity.getBody(); + assertThat(accounts.length >= 21).withFailMessage("Expected 21 accounts, but found " + accounts.length).isTrue(); + assertThat(accounts[0].getName()).isEqualTo("Keith and Keri Donald"); + assertThat(accounts[0].getBeneficiaries().size()).isEqualTo(2); + assertThat(accounts[0].getBeneficiary("Annabelle").getAllocationPercentage()).isEqualTo(Percentage.valueOf("50%")); + } + + @Test + @Disabled + public void getAccount_using_valid_user_should_succeed() { + String url = "/accounts/{accountId}"; + ResponseEntity responseEntity + = restTemplate.withBasicAuth("user", "user") + .getForEntity(url, Account.class, 0); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + Account account = responseEntity.getBody(); + assertThat(account.getName()).isEqualTo("Keith and Keri Donald"); + assertThat(account.getBeneficiaries().size()).isEqualTo(2); + assertThat(account.getBeneficiary("Annabelle").getAllocationPercentage()).isEqualTo(Percentage.valueOf("50%")); + } + + @Test + @Disabled + public void createAccount_using_admin_should_return_201() { + String url = "/accounts"; + // use a unique number to avoid conflicts + String number = "12345%4d".formatted(random.nextInt(10000)); + Account account = new Account(number, "John Doe"); + account.addBeneficiary("Jane Doe"); + ResponseEntity responseEntity + = restTemplate.withBasicAuth("admin", "admin") + .postForEntity(url, account, Void.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + + // TODO-07b: Write a test that verifies that "user"/"user" + // is not permitted to create a new Account + // - Use the code above as a guidance + @Test + public void createAccount_using_user_should_return_403() { + + + + } + + @Test + @Disabled + public void addAndDeleteBeneficiary_using_superadmin_should_succeed() { + // perform both add and delete to avoid issues with side effects + String addUrl = "/accounts/{accountId}/beneficiaries"; + URI newBeneficiaryLocation + = restTemplate.withBasicAuth("superadmin", "superadmin") + .postForLocation(addUrl, "David", 1); + + Beneficiary newBeneficiary + = restTemplate.withBasicAuth("superadmin", "superadmin") + .getForObject(newBeneficiaryLocation, Beneficiary.class); + assertThat(newBeneficiary.getName()).isEqualTo("David"); + + restTemplate.withBasicAuth("superadmin", "superadmin") + .delete(newBeneficiaryLocation); + + // use exchange method to receive a 404 response + ResponseEntity response = + restTemplate.withBasicAuth("superadmin", "superadmin") + .getForEntity(newBeneficiaryLocation, Beneficiary.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + +} diff --git a/lab/42-security-rest/src/test/java/accounts/services/AccountServiceMethodSecurityTest.java b/lab/42-security-rest/src/test/java/accounts/services/AccountServiceMethodSecurityTest.java new file mode 100644 index 0000000..9bebad5 --- /dev/null +++ b/lab/42-security-rest/src/test/java/accounts/services/AccountServiceMethodSecurityTest.java @@ -0,0 +1,60 @@ +package accounts.services; + +import accounts.RestWsApplication; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.*; + +// TODO-12a: Perform method security testing with a running server +// - Take some time to understand what each test is for +// - Remove @Disabled annotation from each test and run it +// - Make sure all tests pass + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +class AccountServiceMethodSecurityTest { + + @Autowired + private TestRestTemplate restTemplate; + + @Test + @Disabled + void getAuthoritiesForUser_should_return_403_for_user() { + + ResponseEntity responseEntity = restTemplate.withBasicAuth("user", "user") + .getForEntity("/authorities?username=user", String.class); + + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @Disabled + void getAuthoritiesForUser_should_return_authorities_for_admin() { + + String[] authorities = restTemplate.withBasicAuth("admin", "admin") + .getForObject("/authorities?username=admin", String[].class); + assertThat(authorities.length).isEqualTo(2); + assertThat(authorities.toString().contains("ROLE_ADMIN")); + assertThat(authorities.toString().contains("ROLE_USER")); + + } + + // TODO-12b: Write a test that verifies that getting authorities + // using "/authorities?username=superadmin" with + // "superadmin"/"superadmin" credential should return + // three roles "ROLE_SUPERADMIN", "ROLE_ADMIN", and + // "ROLE_USER". + @Test + public void getAuthoritiesForUser_should_return_authorities_for_superadmin() { + + + + } + +} \ No newline at end of file diff --git a/lab/42-security-rest/src/test/java/accounts/web/AccountControllerCustomAuthenticationProviderTests.java b/lab/42-security-rest/src/test/java/accounts/web/AccountControllerCustomAuthenticationProviderTests.java new file mode 100644 index 0000000..a15c244 --- /dev/null +++ b/lab/42-security-rest/src/test/java/accounts/web/AccountControllerCustomAuthenticationProviderTests.java @@ -0,0 +1,63 @@ +package accounts.web; + +import accounts.AccountManager; +import accounts.RestWsApplication; +import accounts.security.CustomAuthenticationProvider; +import accounts.services.AccountService; +import config.RestSecurityConfig; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import rewards.internal.account.Account; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +// TODO-20 (Optional): Perform security testing for the user added +// through custom AuthenticationProvider +// - Remove @Disabled annotation from the test and run it +// - Make sure the test passes + +@WebMvcTest(AccountController.class) +@ContextConfiguration(classes = {RestWsApplication.class, RestSecurityConfig.class, CustomAuthenticationProvider.class}) +public class AccountControllerCustomAuthenticationProviderTests { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AccountManager accountManager; + + @MockBean + private AccountService accountService; + + @Test + @Disabled + public void accountDetails_with_spring_credentials_should_return_200() throws Exception { + + // arrange + given(accountManager.getAccount(0L)).willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0").with(httpBasic("spring", "spring"))) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")).andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(0L); + + } + +} + diff --git a/lab/42-security-rest/src/test/java/accounts/web/AccountControllerCustomUserDetailsServiceTests.java b/lab/42-security-rest/src/test/java/accounts/web/AccountControllerCustomUserDetailsServiceTests.java new file mode 100644 index 0000000..0e48313 --- /dev/null +++ b/lab/42-security-rest/src/test/java/accounts/web/AccountControllerCustomUserDetailsServiceTests.java @@ -0,0 +1,84 @@ +package accounts.web; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; + +import accounts.AccountManager; +import accounts.RestWsApplication; +import accounts.security.CustomUserDetailsService; +import accounts.services.AccountService; +import config.RestSecurityConfig; +import rewards.internal.account.Account; + +// TODO-16 (Optional): Perform security testing for the two users added +// through custom UserDetailsService +// - Take some time to understand what each test is for +// - Remove @Disabled annotation from each test and run it +// - Make sure all tests pass + +@WebMvcTest(AccountController.class) +@ContextConfiguration(classes = {RestWsApplication.class, RestSecurityConfig.class, CustomUserDetailsService.class}) +public class AccountControllerCustomUserDetailsServiceTests { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AccountManager accountManager; + + @MockBean + private AccountService accountService; + + @Test + @Disabled + @WithUserDetails("joe") + public void accountDetails_with_joe_credentials_should_return_200() throws Exception { + + // arrange + given(accountManager.getAccount(0L)).willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0")).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")).andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(0L); + + } + + @Test + @Disabled + @WithUserDetails("mary") + public void accountDetails_with_mary_credentials_should_return_200() throws Exception { + + // arrange + given(accountManager.getAccount(0L)).willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0")).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")).andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(0L); + + } + +} + diff --git a/lab/42-security-rest/src/test/java/accounts/web/AccountControllerTests.java b/lab/42-security-rest/src/test/java/accounts/web/AccountControllerTests.java new file mode 100644 index 0000000..cc12329 --- /dev/null +++ b/lab/42-security-rest/src/test/java/accounts/web/AccountControllerTests.java @@ -0,0 +1,290 @@ +package accounts.web; + +import accounts.AccountManager; +import accounts.RestWsApplication; +import accounts.services.AccountService; +import com.fasterxml.jackson.databind.ObjectMapper; +import common.money.Percentage; +import config.RestSecurityConfig; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import rewards.internal.account.Account; + +import java.util.Arrays; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +// TODO-06a: Perform security testing against MVC layer +// - Take some time to understand what each test is for +// - Remove @Disabled annotation from each test and run it +// - Make sure all tests pass + +@WebMvcTest(AccountController.class) +@ContextConfiguration(classes = {RestWsApplication.class, RestSecurityConfig.class}) +public class AccountControllerTests { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AccountManager accountManager; + + @MockBean + private AccountService accountService; + + @Test + @Disabled + @WithMockUser(roles = {"INVALID"}) + void accountSummary_with_invalid_role_should_return_403() throws Exception { + + mockMvc.perform(get("/accounts")) + .andExpect(status().isForbidden()); + } + + @Test + @Disabled + @WithMockUser( roles = {"USER"}) + public void accountDetails_with_USER_role_should_return_200() throws Exception { + + // arrange + given(accountManager.getAccount(0L)).willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")).andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(0L); + + } + + @Test + @Disabled + @WithMockUser(username = "user", password = "user") + public void accountDetails_with_user_credentials_should_return_200() throws Exception { + + // arrange + given(accountManager.getAccount(0L)).willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")).andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(0L); + + } + + @Test + @Disabled + @WithMockUser(username = "admin", password = "admin") + public void accountDetails_with_admin_credentials_should_return_200() throws Exception { + + // arrange + given(accountManager.getAccount(0L)).willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")).andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(0L); + + } + + @Test + @Disabled + @WithMockUser(username = "superadmin", password = "superadmin") + public void accountDetails_with_superadmin_credentials_should_return_200() throws Exception { + + // arrange + given(accountManager.getAccount(0L)).willReturn(new Account("1234567890", "John Doe")); + + // act and assert + mockMvc.perform(get("/accounts/0")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("John Doe")) + .andExpect(jsonPath("number").value("1234567890")); + + // verify + verify(accountManager).getAccount(0L); + + } + + @Test + @Disabled + @WithMockUser(roles = {"USER"}) + public void accountDetailsFail_test_with_USER_role_should_proceed_successfully() throws Exception { + + given(accountManager.getAccount(any(Long.class))) + .willThrow(new IllegalArgumentException("No such account with id " + 0L)); + + mockMvc.perform(get("/accounts/0")) + .andExpect(status().isNotFound()); + + verify(accountManager).getAccount(any(Long.class)); + + } + + @Test + @Disabled + @WithMockUser(roles = {"ADMIN"}) + public void accountSummary_with_ADMIN_role_should_return_200() throws Exception { + + List testAccounts = List.of(new Account("123456789", "John Doe")); + given(accountManager.getAllAccounts()).willReturn(testAccounts); + + mockMvc.perform(get("/accounts")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$..number").value("123456789")) + .andExpect(jsonPath("$..name").value("John Doe")); + + verify(accountManager).getAllAccounts(); + + } + + @Test + @Disabled + @WithMockUser(roles = {"ADMIN", "SUPERADMIN"}) + public void createAccount_with_ADMIN_or_SUPERADMIN_role_should_return_201() throws Exception { + + Account testAccount = new Account("1234512345", "Mary Jones"); + testAccount.setEntityId(21L); + given(accountManager.save(any(Account.class))).willReturn(testAccount); + + mockMvc.perform(post("/accounts") + .contentType(MediaType.APPLICATION_JSON) + .content(asJsonString(testAccount))) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "http://localhost/accounts/21")); + + verify(accountManager).save(any(Account.class)); + + } + + // TODO-06b: Write a test that verifies that a user with "USER" role + // is not permitted to perform POST operation + // - Use the code above (in the previous test) as a guidance + // but without using "given" and "verify" methods. + // (The "given" and "verify" methods are not required for + // this testing because security failure will prevent + // calling a method of a dependency.) + @Test + public void createAccount_with_USER_role_should_return_403() { + + + + } + + @Test + @Disabled + @WithMockUser(roles = {"SUPERADMIN"}) + public void getBeneficiary_with_SUPERADMIN_role_should_return_200() throws Exception { + + Account account = new Account("1234567890", "John Doe"); + account.addBeneficiary("Corgan", new Percentage(0.1)); + given(accountManager.getAccount(0L)).willReturn(account); + + mockMvc.perform(get("/accounts/{accountId}/beneficiaries/{beneficiaryName}", 0L, "Corgan")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("Corgan")) + .andExpect(jsonPath("allocationPercentage").value("0.1")); + + verify(accountManager).getAccount(0L); + } + + @Test + @Disabled + @WithMockUser(roles = {"ADMIN", "SUPERADMIN"}) + public void addBeneficiary_with_ADMIN_or_SUPERADMIN_role_should_return_201() throws Exception { + + mockMvc.perform(post("/accounts/{entityId}/beneficiaries", 0L).content("Kate")) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "http://localhost/accounts/0/beneficiaries/Kate")); + } + + @Test + @Disabled + @WithMockUser(roles = {"USER"}) + public void addBeneficiary_with_USER_role_should_return_403() throws Exception { + + mockMvc.perform(post("/accounts/{entityId}/beneficiaries", 0L).content("Kate")) + .andExpect(status().isForbidden()); + } + + @Test + @Disabled + @WithMockUser(roles = {"SUPERADMIN"}) + public void removeBeneficiary_with_SUPERADMIN_role_should_return_204() throws Exception { + + Account account = new Account("1234567890", "John Doe"); + account.addBeneficiary("Corgan", new Percentage(0.1)); + given(accountManager.getAccount(0L)).willReturn(account); + + mockMvc.perform(delete("/accounts/{entityId}/beneficiaries/{name}", 0L, "Corgan")) + .andExpect(status().isNoContent()); + + verify(accountManager).getAccount(0L); + + } + + @Test + @Disabled + @WithMockUser(roles = {"USER", "ADMIN"}) + public void removeBeneficiary_with_USER_or_ADMIN_role_should_return_403() throws Exception { + + Account account = new Account("1234567890", "John Doe"); + account.addBeneficiary("Corgan", new Percentage(0.1)); + given(accountManager.getAccount(0L)).willReturn(account); + + mockMvc.perform(delete("/accounts/{entityId}/beneficiaries/{name}", 0L, "Corgan")) + .andExpect(status().isForbidden()); + + } + + @Test + @Disabled + @WithMockUser(roles = {"SUPERADMIN"}) + public void removeBeneficiaryFail_test_with_SUPERADMIN_role_should_proceed_successfully() throws Exception { + Account account = new Account("1234567890", "John Doe"); + given(accountManager.getAccount(0L)).willReturn(account); + + mockMvc.perform(delete("/accounts/{entityId}/beneficiaries/{name}", 0L, "Noname")) + .andExpect(status().isNotFound()); + + verify(accountManager).getAccount(0L); + } + + protected static String asJsonString(final Object obj) { + try { + final ObjectMapper mapper = new ObjectMapper(); + return mapper.writeValueAsString(obj); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + +} + diff --git a/lab/44-actuator-solution/build.gradle b/lab/44-actuator-solution/build.gradle new file mode 100644 index 0000000..e5cdbdc --- /dev/null +++ b/lab/44-actuator-solution/build.gradle @@ -0,0 +1,41 @@ +apply plugin: "org.springframework.boot" + +dependencies { + implementation project(':00-rewards-common') + implementation project(':01-rewards-db') + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.micrometer:micrometer-registry-prometheus' +// implementation 'com.wavefront:wavefront-spring-boot-starter' +} + +// Needed for Wavefront dependency +//dependencyManagement { +// imports { +// mavenBom("com.wavefront:wavefront-spring-boot-bom:$wavefrontVersion") +// } +//} + +// Needed for Wavefront dependency +//repositories { +// maven { +// url = 'https://repo.spring.io/snapshot' +// } +//} + +springBoot { + buildInfo { + properties { + name = "actuator app" + additional = [ + javaVersion: System.properties['java.version'], + operatingSystem: "${System.properties['os.name']} (${System.properties['os.version']})" + ] + } + } +} + + diff --git a/lab/44-actuator-solution/pom.xml b/lab/44-actuator-solution/pom.xml new file mode 100644 index 0000000..691a32a --- /dev/null +++ b/lab/44-actuator-solution/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + 44-actuator-solution + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + UTF-8 + accounts.ActuatorApplication + + + + + io.spring.training.core-spring + 00-rewards-common + + + + + io.spring.training.core-spring + 01-rewards-db + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + io.micrometer + micrometer-registry-prometheus + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + org.springframework.boot + spring-boot-starter-aop + + + + org.springframework.boot + spring-boot-starter-security + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + build-info + + + + + + + diff --git a/lab/44-actuator-solution/src/main/java/accounts/ActuatorApplication.java b/lab/44-actuator-solution/src/main/java/accounts/ActuatorApplication.java new file mode 100644 index 0000000..6c6cbeb --- /dev/null +++ b/lab/44-actuator-solution/src/main/java/accounts/ActuatorApplication.java @@ -0,0 +1,19 @@ +package accounts; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Import; + +import config.AppConfig; + +@SpringBootApplication +@Import(AppConfig.class) +@EntityScan("rewards.internal") +public class ActuatorApplication { + + public static void main(String[] args) { + SpringApplication.run(ActuatorApplication.class, args); + } + +} diff --git a/lab/44-actuator-solution/src/main/java/accounts/web/AccountAspect.java b/lab/44-actuator-solution/src/main/java/accounts/web/AccountAspect.java new file mode 100644 index 0000000..8312876 --- /dev/null +++ b/lab/44-actuator-solution/src/main/java/accounts/web/AccountAspect.java @@ -0,0 +1,23 @@ +package accounts.web; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.stereotype.Component; + +@Aspect +@Component +public class AccountAspect { + + private final Counter counter; + + public AccountAspect(MeterRegistry meterRegistry) { + this.counter = meterRegistry.counter("account.fetch", "type", "fromAspect"); + } + + @Before("execution(* accounts.web.AccountController.accountSummary(..))") + public void increment(){ + counter.increment(); + } +} diff --git a/lab/44-actuator-solution/src/main/java/accounts/web/AccountController.java b/lab/44-actuator-solution/src/main/java/accounts/web/AccountController.java new file mode 100644 index 0000000..3f44a96 --- /dev/null +++ b/lab/44-actuator-solution/src/main/java/accounts/web/AccountController.java @@ -0,0 +1,196 @@ +package accounts.web; + +import accounts.AccountManager; +import common.money.Percentage; +import io.micrometer.core.annotation.Timed; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; + +/** + * A controller handling requests for CRUD operations on Accounts and their + * Beneficiaries. + */ +@RestController +public class AccountController { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final AccountManager accountManager; + private final Counter counter; + + /** + * Creates a new AccountController with a given account manager. + */ + public AccountController(AccountManager accountManager, MeterRegistry registry) { + this.accountManager = accountManager; + this.counter = registry.counter("account.fetch", "type", "fromCode"); + } + + /** + * Provide a list of all accounts. + */ + @GetMapping(value = "/accounts") + @Timed(value="account.timer", extraTags = {"source", "accountSummary"}) + public List accountSummary() { + return accountManager.getAllAccounts(); + } + + /** + * Provide the details of an account with the given id. + */ + @Timed(value="account.timer", extraTags = {"source", "accountDetails"}) + @GetMapping(value = "/accounts/{id}") + public Account accountDetails(@PathVariable int id) { + counter.increment(); + + return retrieveAccount(id); + } + + /** + * Creates a new Account, setting its URL as the Location header on the + * response. + */ + @PostMapping(value = "/accounts") + public ResponseEntity createAccount(@RequestBody Account newAccount) { + Account account = accountManager.save(newAccount); + + return entityWithLocation(account.getEntityId()); + } + + /** + * Returns the Beneficiary with the given name for the Account with the given + * id. + */ + @GetMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + public Beneficiary getBeneficiary(@PathVariable int accountId, + @PathVariable String beneficiaryName) { + return retrieveAccount(accountId).getBeneficiary(beneficiaryName); + } + + /** + * Adds a Beneficiary with the given name to the Account with the given id, + * setting its URL as the Location header on the response. + */ + @PostMapping(value = "/accounts/{accountId}/beneficiaries") + public ResponseEntity addBeneficiary(@PathVariable long accountId, @RequestBody String beneficiaryName) { + accountManager.addBeneficiary(accountId, beneficiaryName); + return entityWithLocation(beneficiaryName); + } + + /** + * Removes the Beneficiary with the given name from the Account with the given + * id. + */ + @DeleteMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + @ResponseStatus(HttpStatus.NO_CONTENT) // 204 + public void removeBeneficiary(@PathVariable long accountId, @PathVariable String beneficiaryName) { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + Beneficiary deletedBeneficiary = account.getBeneficiary(beneficiaryName); + + HashMap allocationPercentages = new HashMap<>(); + + // If we are removing the only beneficiary or the beneficiary has an + // allocation of zero we don't need to worry. Otherwise, need to share + // out the benefit of the deleted beneficiary amongst all the others + if (account.getBeneficiaries().size() != 1 + && (!deletedBeneficiary.getAllocationPercentage().equals(Percentage.zero()))) { + // This logic is very simplistic, doesn't account for rounding errors + Percentage p = deletedBeneficiary.getAllocationPercentage(); + int remaining = account.getBeneficiaries().size() - 1; + double extra = p.asDouble() / remaining; + + for (Beneficiary beneficiary : account.getBeneficiaries()) { + if (beneficiary != deletedBeneficiary) { + double newValue = beneficiary.getAllocationPercentage().asDouble() + extra; + allocationPercentages.put(beneficiary.getName(), new Percentage(newValue)); + } + } + } + + accountManager.removeBeneficiary(accountId, beneficiaryName, allocationPercentages); + } + + /** + * Maps UnsupportedOperationException to a 501 Not Implemented HTTP status code. + */ + @ResponseStatus(HttpStatus.NOT_IMPLEMENTED) + @ExceptionHandler({ UnsupportedOperationException.class }) + public void handleUnabletoReallocate(Exception ex) { + logger.error("Exception is: ", ex); + // just return empty 501 + } + + /** + * Maps IllegalArgumentExceptions to a 404 Not Found HTTP status code. + */ + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(IllegalArgumentException.class) + public void handleNotFound(Exception ex) { + logger.error("Exception is: ", ex); + // return empty 404 + } + + /** + * Maps DataIntegrityViolationException to a 409 Conflict HTTP status code. + */ + @ResponseStatus(HttpStatus.CONFLICT) + @ExceptionHandler({ DataIntegrityViolationException.class }) + public void handleAlreadyExists(Exception ex) { + logger.error("Exception is: ", ex); + // return empty 409 + } + + /** + * Finds the Account with the given id, throwing an IllegalArgumentException if + * there is no such Account. + */ + private Account retrieveAccount(long accountId) throws IllegalArgumentException { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + return account; + } + + /** + * Return a response with the location of the new resource. Its URL is assumed + * to be a child of the URL just received. + *

+ * Suppose we have just received an incoming URL of, say, + * http://localhost:8080/accounts and resourceId is + * "12345". Then the URL of the new resource will be + * http://localhost:8080/accounts/12345. + * + * @param resourceId + * Is of the new resource. + * @return + */ + private ResponseEntity entityWithLocation(Object resourceId) { + + // Determines URL of child resource based on the full URL of the given + // request, appending the path info with the given resource Identifier + URI location = ServletUriComponentsBuilder.fromCurrentRequestUri().path("/{childId}").buildAndExpand(resourceId) + .toUri(); + + // Return an HttpEntity object - it will be used to build the + // HttpServletResponse + return ResponseEntity.created(location).build(); + } + +} diff --git a/lab/44-actuator-solution/src/main/java/accounts/web/ActuatorSecurityConfiguration.java b/lab/44-actuator-solution/src/main/java/accounts/web/ActuatorSecurityConfiguration.java new file mode 100644 index 0000000..d59f890 --- /dev/null +++ b/lab/44-actuator-solution/src/main/java/accounts/web/ActuatorSecurityConfiguration.java @@ -0,0 +1,58 @@ +package accounts.web; + +import static org.springframework.security.config.Customizer.withDefaults; + +import org.springframework.boot.actuate.autoconfigure.condition.ConditionsReportEndpoint; +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; +import org.springframework.boot.actuate.health.HealthEndpoint; +import org.springframework.boot.actuate.info.InfoEndpoint; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class ActuatorSecurityConfiguration { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + // TODO-23: Configure access control to actuator endpoints as following + // - Anybody can access "health" and "info" endpoints + // - ADMIN role can access "conditions" endpoint + // - ACTUATOR role can access all the other endpoints + + // @formatter:off + http.authorizeHttpRequests((authz) -> authz + .requestMatchers(EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class)).permitAll() + .requestMatchers(EndpointRequest.to(ConditionsReportEndpoint.class)).hasRole("ADMIN") + .requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole("ACTUATOR") + .anyRequest().authenticated()) + .httpBasic(withDefaults()) + .csrf(CsrfConfigurer::disable); + // @formatter:on + + return http.build(); + } + + + @Bean + public InMemoryUserDetailsManager userDetailsService(PasswordEncoder passwordEncoder) { + + UserDetails actuator = User.withUsername("actuator").password(passwordEncoder.encode("actuator")).roles("ACTUATOR").build(); + UserDetails admin = User.withUsername("admin").password(passwordEncoder.encode("admin")).roles("ACTUATOR", "ADMIN").build(); + + return new InMemoryUserDetailsManager(actuator, admin); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } +} \ No newline at end of file diff --git a/lab/44-actuator-solution/src/main/java/accounts/web/RestaurantCustomEndpoint.java b/lab/44-actuator-solution/src/main/java/accounts/web/RestaurantCustomEndpoint.java new file mode 100644 index 0000000..80d5d5e --- /dev/null +++ b/lab/44-actuator-solution/src/main/java/accounts/web/RestaurantCustomEndpoint.java @@ -0,0 +1,43 @@ +package accounts.web; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.WriteOperation; +import org.springframework.stereotype.Component; +import rewards.internal.restaurant.RestaurantRepository; + +import java.util.HashMap; +import java.util.Map; + +@Component +@Endpoint(id = "restaurant") +public class RestaurantCustomEndpoint { + + final Map map = new HashMap<>(); + + public RestaurantCustomEndpoint(RestaurantRepository restaurantRepository, + @Value("${info.restaurant.location: New York}") String location) { + Long restaurantCount = restaurantRepository.getRestaurantCount(); + map.put("restaurant.count", restaurantCount.toString()); + map.put("restaurant.location", location); + } + + @ReadOperation + public Map readOperation() { + return map; + } + + @WriteOperation + public Map writeOperation(String key, String value) { + map.put(key, value); + return map; + } + + @DeleteOperation + public Map deleteOperation() { + map.clear(); + return map; + } +} diff --git a/lab/44-actuator-solution/src/main/java/accounts/web/RestaurantHealthCheck.java b/lab/44-actuator-solution/src/main/java/accounts/web/RestaurantHealthCheck.java new file mode 100644 index 0000000..4bfd892 --- /dev/null +++ b/lab/44-actuator-solution/src/main/java/accounts/web/RestaurantHealthCheck.java @@ -0,0 +1,29 @@ +package accounts.web; + +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.actuate.health.Status; +import org.springframework.stereotype.Component; +import rewards.internal.restaurant.RestaurantRepository; + +@Component +public class RestaurantHealthCheck implements HealthIndicator { + private final RestaurantRepository restaurantRepository; + + public RestaurantHealthCheck(RestaurantRepository restaurantRepository) { + this.restaurantRepository = restaurantRepository; + } + + @Override + public Health health() { + Long restaurantCount = restaurantRepository.getRestaurantCount(); + if (restaurantCount > 0) { + return Health.up() + .withDetail("restaurantCount", restaurantCount) + .build(); + } else { + return Health.status("NO_RESTAURANTS") + .build(); + } + } +} diff --git a/lab/44-actuator-solution/src/main/java/accounts/web/RestaurantHealthCheck2.java b/lab/44-actuator-solution/src/main/java/accounts/web/RestaurantHealthCheck2.java new file mode 100644 index 0000000..99d1322 --- /dev/null +++ b/lab/44-actuator-solution/src/main/java/accounts/web/RestaurantHealthCheck2.java @@ -0,0 +1,29 @@ +package accounts.web; + +import org.springframework.boot.actuate.health.AbstractHealthIndicator; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.Status; +import org.springframework.stereotype.Component; +import rewards.internal.restaurant.RestaurantRepository; + +// This is a HealthIndicator example that uses the AbstractHealthIndicator +@Component +public class RestaurantHealthCheck2 extends AbstractHealthIndicator { + private final RestaurantRepository restaurantRepository; + + public RestaurantHealthCheck2(RestaurantRepository restaurantRepository) { + this.restaurantRepository = restaurantRepository; + } + + @Override + protected void doHealthCheck(Health.Builder builder) { + + Long restaurantCount = restaurantRepository.getRestaurantCount(); + if (restaurantCount > 0) { + builder.up() + .withDetail("restaurantCount", restaurantCount); + } else { + builder.status("NO_RESTAURANTS"); + } + } +} diff --git a/lab/44-actuator-solution/src/main/resources/application.properties b/lab/44-actuator-solution/src/main/resources/application.properties new file mode 100644 index 0000000..28e35b4 --- /dev/null +++ b/lab/44-actuator-solution/src/main/resources/application.properties @@ -0,0 +1,32 @@ +# Solution +spring.jpa.hibernate.ddl-auto=none +spring.sql.init.schema-locations=classpath:/rewards/testdb/schema.sql + +# Load the accounts only, no restaurant +spring.sql.init.data-locations=classpath:/data-no-restaurants.sql + +# Actuator properties +management.endpoints.web.exposure.include=* + +# Custom application information +management.info.java.enabled=true +management.info.env.enabled=true +info.restaurant.location=New York +info.restaurant.discountPercentage=10 + +# Organize health indicators into 3 groups: "system", "web", and "application" +# For "system" group, configure "show-details" with "always" +management.endpoint.health.group.system.include=diskSpace,db +management.endpoint.health.group.system.show-details=always +management.endpoint.health.group.web.include=ping +management.endpoint.health.group.application.include=restaurantHealthCheck,restaurantHealthCheck2 +management.endpoint.health.group.application.show-details=always + +# Enable scanning of @Timed annotations +management.observations.annotations.enabled=true + +# Set the severity order for the "application" health group +management.endpoint.health.group.application.status.order=NO_RESTAURANTS,DOWN,UP + +# Enable JMX for accessing Actuator endpoints +spring.jmx.enabled=true \ No newline at end of file diff --git a/lab/44-actuator-solution/src/main/resources/data-no-restaurants.sql b/lab/44-actuator-solution/src/main/resources/data-no-restaurants.sql new file mode 100644 index 0000000..a5f0dce --- /dev/null +++ b/lab/44-actuator-solution/src/main/resources/data-no-restaurants.sql @@ -0,0 +1,76 @@ +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (17, 'Daniel', 1.0, 0.00); + +-- insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) values ('1234567894', 'AppleBees1', .08, 'A'); \ No newline at end of file diff --git a/lab/44-actuator-solution/src/main/resources/data-with-restaurants.sql b/lab/44-actuator-solution/src/main/resources/data-with-restaurants.sql new file mode 100644 index 0000000..88e41a8 --- /dev/null +++ b/lab/44-actuator-solution/src/main/resources/data-with-restaurants.sql @@ -0,0 +1,76 @@ +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (17, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) values ('1234567894', 'AppleBees', .08, 'A'); \ No newline at end of file diff --git a/lab/44-actuator-solution/src/main/resources/schema.sql b/lab/44-actuator-solution/src/main/resources/schema.sql new file mode 100644 index 0000000..8845c34 --- /dev/null +++ b/lab/44-actuator-solution/src/main/resources/schema.sql @@ -0,0 +1,20 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, unique(NUMBER)); +create table T_ACCOUNT_CREDIT_CARD (ID integer identity primary key, ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(3,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_CREDIT_CARD add constraint FK_ACCOUNT_CREDIT_CARD foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; \ No newline at end of file diff --git a/lab/44-actuator-solution/src/main/resources/static/README.md b/lab/44-actuator-solution/src/main/resources/static/README.md new file mode 100644 index 0000000..0e64b37 --- /dev/null +++ b/lab/44-actuator-solution/src/main/resources/static/README.md @@ -0,0 +1,7 @@ +# Spring Boot Resources + +By default, Spring Boot looks for resources in `classpath:static`, which is +this location. However `if src/main/webapp` exists it will look in there +first. + +Spring Boot is also conveniently configured to map '/' to `index.html`. \ No newline at end of file diff --git a/lab/44-actuator-solution/src/main/resources/static/index.html b/lab/44-actuator-solution/src/main/resources/static/index.html new file mode 100644 index 0000000..c214d42 --- /dev/null +++ b/lab/44-actuator-solution/src/main/resources/static/index.html @@ -0,0 +1,69 @@ + + + + + + + + + + + actuator-solution: Using Spring Boot Actuators + + + + +

+
+ +
+ +
+ +

actuators-solution: Using Actuators

+ +

Links used in the lab:

+ +

Actuators

+ + +

Metrics

+ + + +
+
+ + + diff --git a/lab/44-actuator-solution/src/test/java/accounts/client/AccountClientCustomEndpointTests.java b/lab/44-actuator-solution/src/test/java/accounts/client/AccountClientCustomEndpointTests.java new file mode 100644 index 0000000..8357d62 --- /dev/null +++ b/lab/44-actuator-solution/src/test/java/accounts/client/AccountClientCustomEndpointTests.java @@ -0,0 +1,31 @@ +package accounts.client; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.autoconfigure.metrics.export.wavefront.WavefrontMetricsExportAutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@EnableAutoConfiguration(exclude = {WavefrontMetricsExportAutoConfiguration.class}) +public class AccountClientCustomEndpointTests { + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void restaurant_custom_endpoint_returns_valid_data() { + ResponseEntity responseEntity + = restTemplate.withBasicAuth("actuator", "actuator") + .getForEntity("/actuator/restaurant", String.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(responseEntity.getBody()).contains("restaurant.location").contains("New York"); + } + +} \ No newline at end of file diff --git a/lab/44-actuator-solution/src/test/java/accounts/client/AccountClientSecurityTests.java b/lab/44-actuator-solution/src/test/java/accounts/client/AccountClientSecurityTests.java new file mode 100644 index 0000000..0658a1e --- /dev/null +++ b/lab/44-actuator-solution/src/test/java/accounts/client/AccountClientSecurityTests.java @@ -0,0 +1,60 @@ +package accounts.client; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.autoconfigure.metrics.export.wavefront.WavefrontMetricsExportAutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@EnableAutoConfiguration(exclude = {WavefrontMetricsExportAutoConfiguration.class}) +public class AccountClientSecurityTests { + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void any_user_can_access_health_endpoint() { + ResponseEntity responseEntity + = restTemplate.getForEntity("/actuator/health", String.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + public void any_user_can_access_info_endpoint() { + ResponseEntity responseEntity + = restTemplate.getForEntity("/actuator/info", String.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + public void any_user_cannot_access_conditions_endpoint() { + ResponseEntity responseEntity + = restTemplate.withBasicAuth("anyuser", "anyuser") + .getForEntity("/actuator/conditions", String.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + public void user_in_ADMIN_role_can_access_conditions_endpoint() { + ResponseEntity responseEntity + = restTemplate.withBasicAuth("admin", "admin") + .getForEntity("/actuator/conditions", String.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + public void user_in_ACTUATOR_role_cannot_access_conditions_endpoint() { + ResponseEntity responseEntity + = restTemplate.withBasicAuth("actuator", "actuator") + .getForEntity("/actuator/conditions", String.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + +} \ No newline at end of file diff --git a/lab/44-actuator-solution/src/test/java/accounts/web/AccountControllerTests.java b/lab/44-actuator-solution/src/test/java/accounts/web/AccountControllerTests.java new file mode 100644 index 0000000..6282b44 --- /dev/null +++ b/lab/44-actuator-solution/src/test/java/accounts/web/AccountControllerTests.java @@ -0,0 +1,128 @@ +package accounts.web; + +import accounts.internal.StubAccountManager; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +//import org.junit.Assert; + +/** + * A JUnit test case testing the AccountController. + */ +public class AccountControllerTests { + + private AccountController controller; + private MeterRegistry registry; + private Counter counter; + + @BeforeEach + public void setUp() { + registry = mock(MeterRegistry.class); + counter = mock(Counter.class); + doReturn(counter).when(registry).counter(any(String.class), any(String.class), any(String.class)); + + controller = new AccountController(new StubAccountManager(), registry); + } + + @Test + public void testHandleDetailsRequest() { + Account account = controller.accountDetails(0); + assertNotNull(account); + assertEquals(Long.valueOf(0), account.getEntityId()); + + verify(registry).counter("account.fetch", "type", "fromCode"); + verify(counter).increment(); + } + + @Test + public void testHandleSummaryRequest() { + List accounts = controller.accountSummary(); + assertNotNull(accounts); + assertEquals(1, accounts.size()); + assertEquals(Long.valueOf(0), accounts.get(0).getEntityId()); + } + + @Test + public void testCreateAccount() { + Account newAccount = new Account("11223344", "Test"); + + // ServletUriComponentsBuilder expects to find the HttpRequest in the + // current thread (Spring MVC does this for you). For our test, we need + // to add a mock request manually + setupFakeRequest("http://localhost/accounts"); + + HttpEntity result = controller.createAccount(newAccount); + assertNotNull(result); + + // See StubAccountManager.nextEntityId - initialized to 3 + assertEquals("http://localhost/accounts/3", result.getHeaders().getLocation().toString()); + } + + @Test + public void testGetBeneficiary() { + Beneficiary beneficiary = controller.getBeneficiary(0, "Corgan"); + assertNotNull(beneficiary); + assertEquals(Long.valueOf(1), beneficiary.getEntityId()); + } + + @Test + public void testAddBeneficiary() { + + // ServletUriComponentsBuilder expects to find the HttpRequest in the + // current thread (Spring MVC does this for you). For our test, we need + // to add a mock request manually + setupFakeRequest("http://localhost/accounts/0/beneficiaries"); + + HttpEntity result = controller.addBeneficiary(0L, "Test2"); + assertNotNull(result); + assertEquals("http://localhost/accounts/0/beneficiaries/Test2", result.getHeaders().getLocation().toString()); + } + + @Test + public void testDeleteBeneficiary() { + controller.removeBeneficiary(0L, "Corgan"); + } + + @Test + public void testDeleteBeneficiaryFail() { + assertThrows(IllegalArgumentException.class, () -> { + controller.removeBeneficiary(0L, "Fred"); + }); + } + + /** + * Add a mocked up HttpServletRequest to Spring's internal request-context + * holder. Normally the DispatcherServlet does this, but we must do it + * manually to run our test. + * + * @param url + * The URL we are creating the fake request for. + */ + private void setupFakeRequest(String url) { + String requestURI = url.substring(16); // Drop "http://localhost" + + // We can use Spring's convenient mock implementation. Defaults to + // localhost in the URL. Since we only need the URL, we don't need + // to setup anything else in the request. + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestURI); + + // Puts the fake request in the current thread for the + // ServletUriComponentsBuilder to initialize itself from later. + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + +} diff --git a/lab/44-actuator-solution/src/test/java/accounts/web/RestaurantHealthCheckTest.java b/lab/44-actuator-solution/src/test/java/accounts/web/RestaurantHealthCheckTest.java new file mode 100644 index 0000000..515a018 --- /dev/null +++ b/lab/44-actuator-solution/src/test/java/accounts/web/RestaurantHealthCheckTest.java @@ -0,0 +1,41 @@ +package accounts.web; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.Status; +import rewards.internal.restaurant.JpaRestaurantRepository; +import rewards.internal.restaurant.RestaurantRepository; + +import static org.mockito.Mockito.*; + +public class RestaurantHealthCheckTest { + private RestaurantHealthCheck restaurantHealthCheck; + private RestaurantRepository restaurantRepository; + + @BeforeEach + public void setUp() { + restaurantRepository = mock(JpaRestaurantRepository.class); + restaurantHealthCheck = new RestaurantHealthCheck(restaurantRepository); + } + + @Test + public void testHealthReturnsUpIfThereAreRestaurants() { + doReturn(1L).when(restaurantRepository).getRestaurantCount(); + + Health result = restaurantHealthCheck.health(); + + verify(restaurantRepository).getRestaurantCount(); + assert(result.getStatus()).equals(Status.UP); + } + + @Test + public void testHealthReturnsDownIfThereAreNoRestaurants() { + doReturn(0L).when(restaurantRepository).getRestaurantCount(); + + Health result = restaurantHealthCheck.health(); + + verify(restaurantRepository).getRestaurantCount(); + assert(result.getStatus()).equals(new Status("NO_RESTAURANTS")); + } +} diff --git a/lab/44-actuator/build.gradle b/lab/44-actuator/build.gradle new file mode 100644 index 0000000..48f7748 --- /dev/null +++ b/lab/44-actuator/build.gradle @@ -0,0 +1,14 @@ +apply plugin: "org.springframework.boot" + +dependencies { + implementation project(':00-rewards-common') + implementation project(':01-rewards-db') + implementation 'org.springframework.boot:spring-boot-starter-web' + // TO-DO-01: Note that the Actuator starter is already here for you. + implementation 'org.springframework.boot:spring-boot-starter-actuator' + developmentOnly 'org.springframework.boot:spring-boot-devtools' +} + +// TO-DO-07: Add BuildInfo task to the Spring Boot Gradle plugin + +// TO-DO-08: Add additional properties of name, javaVersion, operatingSystem diff --git a/lab/44-actuator/pom.xml b/lab/44-actuator/pom.xml new file mode 100644 index 0000000..0b002dd --- /dev/null +++ b/lab/44-actuator/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + 44-actuator + + Spring Training + https://spring.io/training + + jar + + io.spring.training.core-spring + parentProject + 3.3.1 + + + UTF-8 + accounts.ActuatorApplication + + + + + io.spring.training.core-spring + 00-rewards-common + + + + + io.spring.training.core-spring + 01-rewards-db + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/lab/44-actuator/src/main/java/accounts/ActuatorApplication.java b/lab/44-actuator/src/main/java/accounts/ActuatorApplication.java new file mode 100644 index 0000000..8ec3683 --- /dev/null +++ b/lab/44-actuator/src/main/java/accounts/ActuatorApplication.java @@ -0,0 +1,112 @@ +package accounts; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Import; + +import config.AppConfig; + +/** + * TODO-00: In this lab, you are going to exercise the following: + * - Exposing and accessing various actuator endpoints + * - Changing logging level of a package by sending post request to "loggers" endpoint + * - Publishing build information via "info" endpoint + * - Adding micrometer counter metric with a tag + * - Adding micrometer timer metric + * - Adding custom health indicator + * - Configuring security against actuator endpoints + * - Using AOP for counter operation (optional step) + * + * TODO-01: Note that the Actuator starter is already set up for you. + * (Look for TO-DO-01 in the pom.xml or build.gradle) + * + * TODO-02: Run this application. Try these URLs: + * - http://localhost:8080/actuator - should work + * - http://localhost:8080/actuator/metrics - fails (404), why? + * + * TODO-03: Expose some endpoints + * (Look for TO-DO-03 in application.properties) + * + * TODO-04: Expose all endpoints + * (Look for TO-DO-04 in application.properties) + * + * TODO-05: Change log level via ./actuator/loggers endpoint + * - Verify the current logging level of the "accounts.web" package is DEBUG + * (Access localhost:8080/actuator/loggers/accounts.web) + * - Add "logger.debug("Logging message within accountSummary()");" inside + * "accountSummary()" method in the "AccountController" class - this + * message will be used to verify if we can change the + * logging level without restarting the application + * - Restart the application and access "/accounts" URL and verify + * the log message gets displayed + * - Change logging level of "accounts.web" package to INFO using curl + * command below (or use Postman) + * curl -i -XPOST -H"Content-Type: application/json" localhost:8080/actuator/loggers/accounts.web -d'{"configuredLevel": "INFO"}' + * - Access "/accounts" URL (WITHOUT restarting the application) and verify + * the logging message no longer gets displayed + * + * TODO-06: Publish build information + * - Add an appropriate plugin to pom.xml (for Maven) or BuildInfo task to + * build.gradle (for Gradle) + * - Rebuild the application preferably at the command line + * ./mvnw -pl 00-rewards-common -pl 01-rewards-db -pl 44-actuator clean install (for Maven) + * ./gradlew 44-actuator:clean 44-actuator:build (for Gradle) + * - Verify the presence of the build-info.properties file as following + * ./target/classes/META-INF/build-info.properties (for Maven) + * ./build/resources/main/META-INF/build-info.properties (for Gradle) + * - Restart the application and access "info" endpoint and verify the build + * info gets displayed + * + * TODO-07 (Optional): Add additional properties to the info endpoint + + * + * ------------------------------------------------ + * + * TODO-14: Look for TO-DO-14 in application.properties + * + * ------------------------------------------------ + * + * TODO-17: Verify the behavior of custom health indicator + * - Let the application to restart (via devtools) + * - Access the health indicator - it should be DOWN as there are no restaurants. + * + * TODO-18: Verify the behavior of custom health indicator with change + * - Modify the `spring.sql.init.data-locations` property in the application.properties + * to use `data-with-restaurants.sql` + * - Let the application to restart (via devtools) + * - Access the health indicator - it should be UP this time + * + * ------------------------------------------------ + * + * TODO-20: Look for "TO-DO-20: Organize health indicators into groups" + * in the application.properties + * + */ +@SpringBootApplication +@Import(AppConfig.class) +@EntityScan("rewards.internal") +public class ActuatorApplication { + + public static void main(String[] args) { + SpringApplication.run(ActuatorApplication.class, args); + } + +} + +/* + * TODO-27 (Optional): Access Actuator endpoints using JMX + * (If you are short on time, skip this step.) + * - Add "spring.jmx.enabled=true" to the "application.properties" + * - Restart the application + * - In a terminal window, run "jconsole" (from /bin) + * - Select "accounts.ActuatorApplication" under "Local Process" + * then click "Connect" + * - Click "insecure connection" if prompted + * - Select the MBeans tab, find the "org.springframework.boot" + * folder, then open the "Endpoint" sub-folder + * - Note that all the actuator endpoints ARE exposed for JMX + * - Expand Health->Operations->health + * - Click "health" button on the top right pane + * - Observe the health data gets displayed + */ \ No newline at end of file diff --git a/lab/44-actuator/src/main/java/accounts/web/AccountAspect.java b/lab/44-actuator/src/main/java/accounts/web/AccountAspect.java new file mode 100644 index 0000000..c314f39 --- /dev/null +++ b/lab/44-actuator/src/main/java/accounts/web/AccountAspect.java @@ -0,0 +1,17 @@ +package accounts.web; + +/* + * TODO-26 (Optional): Use AOP for counting logic + * - Add `spring-boot-starter-aop` starter to the `pom.xml` or the + * `build.gradle`. (You might want to refresh your IDE so that + * it picks up the change in the `pom.xml` or the `build.gradle` file.) + * - Make this class an Aspect, through which `account.fetch` counter, + * which has a tag of `type`/`fromAspect` key/value pair, gets incremented + * every time `accountSummary` method of the `AccountController` class + * is invoked + * - Make this a component by using a proper annotation + * - Access `/accounts` several times and verify the metrics of + * `/actuator/metrics/account.fetch?tag=type:fromAspect + */ +public class AccountAspect { +} diff --git a/lab/44-actuator/src/main/java/accounts/web/AccountController.java b/lab/44-actuator/src/main/java/accounts/web/AccountController.java new file mode 100644 index 0000000..558ef7c --- /dev/null +++ b/lab/44-actuator/src/main/java/accounts/web/AccountController.java @@ -0,0 +1,209 @@ +package accounts.web; + +import accounts.AccountManager; +import common.money.Percentage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; + +/** + * A controller handling requests for CRUD operations on Accounts and their + * Beneficiaries. + * + * TODO-11: Access the new "/metrics/account.fetch" metric + * - Let the application get restarted via devtools + * - Access "/metrics" endpoint, and verify the presence of "account.fetch" metric + * - Access some accounts (i.e. http://localhost:8080/accounts/1) + * - View the counter value at http://localhost:8080/actuator/metrics/account.fetch + * - Restart the application. What happens to the counter? + */ +@RestController +public class AccountController { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final AccountManager accountManager; + + // TODO-08: Add a Micrometer Counter + // - Inject a MeterRegistry through constructor injection + // (Modify the existing constructor below) + // - Create a Counter from the MeterRegistry: name the counter "account.fetch" + // with a tag of "type"/"fromCode" key/value pair + public AccountController(AccountManager accountManager) { + this.accountManager = accountManager; + } + + /** + * Provide a list of all accounts. + * + * TODO-12: Add Timer metric + * - Add "spring-boot-starter-aop" starter to the pom.xml or the build.gradle + * - Add @Timed annotation to this method + * - Set the metric name to "account.timer" + * - Set a extra tag with "source"/"accountSummary" key/value pair + * - Add the property to enable scanning of the @Timed annotation in the application.properties + */ + @GetMapping(value = "/accounts") + public List accountSummary() { + return accountManager.getAllAccounts(); + } + + /** + * + * TODO-09: Increment the Counter each time "accountDetails" method below is called. + * - Add code to increment the counter + * + * ---------------------------------------------------- + * + * TODO-13: Add Timer metric + * - Add @Timed annotation to this method + * - Set the metric name to "account.timer" + * - Set extra tag with "source"/"accountDetails" key/value pair + */ + @GetMapping(value = "/accounts/{id}") + public Account accountDetails(@PathVariable int id) { + + return retrieveAccount(id); + } + + /** + * Creates a new Account, setting its URL as the Location header on the + * response. + */ + @PostMapping(value = "/accounts") + public ResponseEntity createAccount(@RequestBody Account newAccount) { + Account account = accountManager.save(newAccount); + + return entityWithLocation(account.getEntityId()); + } + + /** + * Returns the Beneficiary with the given name for the Account with the given + * id. + */ + @GetMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + public Beneficiary getBeneficiary(@PathVariable int accountId, + @PathVariable String beneficiaryName) { + return retrieveAccount(accountId).getBeneficiary(beneficiaryName); + } + + /** + * Adds a Beneficiary with the given name to the Account with the given id, + * setting its URL as the Location header on the response. + */ + @PostMapping(value = "/accounts/{accountId}/beneficiaries") + public ResponseEntity addBeneficiary(@PathVariable long accountId, @RequestBody String beneficiaryName) { + accountManager.addBeneficiary(accountId, beneficiaryName); + return entityWithLocation(beneficiaryName); + } + + /** + * Removes the Beneficiary with the given name from the Account with the given + * id. + */ + @DeleteMapping(value = "/accounts/{accountId}/beneficiaries/{beneficiaryName}") + @ResponseStatus(HttpStatus.NO_CONTENT) // 204 + public void removeBeneficiary(@PathVariable long accountId, @PathVariable String beneficiaryName) { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + Beneficiary deletedBeneficiary = account.getBeneficiary(beneficiaryName); + + HashMap allocationPercentages = new HashMap<>(); + + // If we are removing the only beneficiary or the beneficiary has an + // allocation of zero we don't need to worry. Otherwise, need to share + // out the benefit of the deleted beneficiary amongst all the others + if (account.getBeneficiaries().size() != 1 + && (!deletedBeneficiary.getAllocationPercentage().equals(Percentage.zero()))) { + // This logic is very simplistic, doesn't account for rounding errors + Percentage p = deletedBeneficiary.getAllocationPercentage(); + int remaining = account.getBeneficiaries().size() - 1; + double extra = p.asDouble() / remaining; + + for (Beneficiary beneficiary : account.getBeneficiaries()) { + if (beneficiary != deletedBeneficiary) { + double newValue = beneficiary.getAllocationPercentage().asDouble() + extra; + allocationPercentages.put(beneficiary.getName(), new Percentage(newValue)); + } + } + } + + accountManager.removeBeneficiary(accountId, beneficiaryName, allocationPercentages); + } + + /** + * Maps UnsupportedOperationException to a 501 Not Implemented HTTP status code. + */ + @ResponseStatus(HttpStatus.NOT_IMPLEMENTED) + @ExceptionHandler({ UnsupportedOperationException.class }) + public void handleUnabletoReallocate(Exception ex) { + logger.error("Exception is: ", ex); + // just return empty 501 + } + + /** + * Maps IllegalArgumentExceptions to a 404 Not Found HTTP status code. + */ + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(IllegalArgumentException.class) + public void handleNotFound(Exception ex) { + logger.error("Exception is: ", ex); + // return empty 404 + } + + /** + * Maps DataIntegrityViolationException to a 409 Conflict HTTP status code. + */ + @ResponseStatus(HttpStatus.CONFLICT) + @ExceptionHandler({ DataIntegrityViolationException.class }) + public void handleAlreadyExists(Exception ex) { + logger.error("Exception is: ", ex); + // return empty 409 + } + + /** + * Finds the Account with the given id, throwing an IllegalArgumentException if + * there is no such Account. + */ + private Account retrieveAccount(long accountId) throws IllegalArgumentException { + Account account = accountManager.getAccount(accountId); + if (account == null) { + throw new IllegalArgumentException("No such account with id " + accountId); + } + return account; + } + + /** + * Return a response with the location of the new resource. Its URL is assumed + * to be a child of the URL just received. + * + * Suppose we have just received an incoming URL of, say, + * http://localhost:8080/accounts and resourceId is + * "12345". Then the URL of the new resource will be + * http://localhost:8080/accounts/12345. + */ + private ResponseEntity entityWithLocation(Object resourceId) { + + // Determines URL of child resource based on the full URL of the given + // request, appending the path info with the given resource Identifier + URI location = ServletUriComponentsBuilder.fromCurrentRequestUri().path("/{childId}").buildAndExpand(resourceId) + .toUri(); + + // Return an HttpEntity object - it will be used to build the + // HttpServletResponse + return ResponseEntity.created(location).build(); + } + +} diff --git a/lab/44-actuator/src/main/java/accounts/web/ActuatorSecurityConfiguration.java b/lab/44-actuator/src/main/java/accounts/web/ActuatorSecurityConfiguration.java new file mode 100644 index 0000000..7b3f911 --- /dev/null +++ b/lab/44-actuator/src/main/java/accounts/web/ActuatorSecurityConfiguration.java @@ -0,0 +1,48 @@ +package accounts.web; + +// TODO-21: Add Spring Boot security starter to the pom.xml or build.gradle +// - You might want to refresh the IDE so that it picks up the change in the build file +// +// TODO-22: Uncomment code below until there is no compile error + +//import static org.springframework.security.config.Customizer.withDefaults; + +//@Configuration +public class ActuatorSecurityConfiguration { + +// @Bean +// public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { +// +// // TODO-23: Configure access control to actuator endpoints as following +// // - Anybody can access "health" and "info" endpoints +// // - ADMIN role can access "conditions" endpoint +// // - ACTUATOR role can access all the other endpoints +// +// // @formatter:off +// http.authorizeHttpRequests((authz) -> authz +// .requestMatchers(/* Add code here */).permitAll() +// .requestMatchers(/* Add code here */).hasRole("ADMIN") +// .requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole("ACTUATOR") +// .anyRequest().authenticated()) +// .httpBasic(withDefaults()) +// .csrf(CsrfConfigurer::disable); +// // @formatter:on +// +// return http.build(); +// } +// +// +// @Bean +// public InMemoryUserDetailsManager userDetailsService(PasswordEncoder passwordEncoder) { +// +// UserDetails actuator = User.withUsername("actuator").password(passwordEncoder.encode("actuator")).roles("ACTUATOR").build(); +// UserDetails admin = User.withUsername("admin").password(passwordEncoder.encode("admin")).roles("ACTUATOR", "ADMIN").build(); +// +// return new InMemoryUserDetailsManager(actuator, admin); +// } +// +// @Bean +// public PasswordEncoder passwordEncoder() { +// return PasswordEncoderFactories.createDelegatingPasswordEncoder(); +// } +} diff --git a/lab/44-actuator/src/main/java/accounts/web/RestaurantHealthCheck.java b/lab/44-actuator/src/main/java/accounts/web/RestaurantHealthCheck.java new file mode 100644 index 0000000..15970a7 --- /dev/null +++ b/lab/44-actuator/src/main/java/accounts/web/RestaurantHealthCheck.java @@ -0,0 +1,30 @@ +package accounts.web; + +/** + * TODO-16a: Create custom health indicator + * - Make this class implement HealthIndicator interface + * - Make this class a component + * - Inject RestaurantRepository through constructor injection + * - health() method should return DOWN if the repository is empty + * (no restaurants) or UP otherwise. (Note that RestaurantRepository + * has a method that returns number of restaurants.) + */ +public class RestaurantHealthCheck { + +} + + +/** + * TODO-25 (Optional): Experiment with HealthIndicator above + * - Change "spring.sql.init.data-locations" property in the + * "application.properties" file back to use "data-no-restaurants.sql" + * - Include the restaurant count as extra detail when DOWN state. + * - Instead of returning DOWN when there are no restaurants, + * define and use a custom status called NO_RESTAURANTS. + * - When there are no restaurants in the DB, what top-level status + * is returned for the "application" health group? + * - Set "management.endpoint.health.group.application.status.order" property + * in the "application.properties" file so that NO_RESTAURANTS + * gets displayed as top-level status for the "application" health group. + * - Restart the application and verify the result. + */ \ No newline at end of file diff --git a/lab/44-actuator/src/main/resources/application.properties b/lab/44-actuator/src/main/resources/application.properties new file mode 100644 index 0000000..dee636f --- /dev/null +++ b/lab/44-actuator/src/main/resources/application.properties @@ -0,0 +1,62 @@ +spring.jpa.hibernate.ddl-auto=none +spring.sql.init.schema-locations=classpath:/rewards/testdb/schema.sql + +# Load the accounts only, no restaurant +spring.sql.init.data-locations=classpath:/data-no-restaurants.sql + +# TO-DO-03: Expose some endpoints +# - Set appropriate property to expose beans and metrics endpoints +# - Let the application restart (via Spring Boot devtools) and access +# beans and metrics endpoints again +# - The beans and metrics endpoints should work now +# - Try a metric: http://localhost:8080/actuator/metrics/jvm.memory.max +# - But what about health endpoint? + +# ---------------------------------------------------- +# +# TO-DO-04: Expose all endpoints +# - Modify the above property to expose all endpoints +# - Let the application restart (via Spring Boot devtools) and +# verify that all endpoints now work +# - Access some metrics such as following: +# +# http://localhost:8080/actuator/beans +# http://localhost:8080/actuator/health +# http://localhost:8080/actuator/info +# http://localhost:8080/actuator/mappings +# http://localhost:8080/actuator/loggers +# http://localhost:8080/actuator/metrics/jvm.memory.max +# +# http://localhost:8080/actuator/metrics/http.server.requests +# http://localhost:8080/actuator/metrics/http.server.requests?tag=method:GET +# http://localhost:8080/actuator/metrics/http.server.requests?tag=uri:/actuator/beans +# +# http://localhost:8080/notexistent (404 is expected) +# http://localhost:8080/actuator/metrics/http.server.requests?tag=status:404 + +# ---------------------------------------------------- +# TO-DO-07 (Optional): Add additional properties to the info endpoint +# - Add the Java runtime information +# - Add custom application information. Feel free to see the lab document +# or solution project for more detailed instruction +# - Restart the application and access "info" endpoint and verify +# additional properties are displayed + +# ---------------------------------------------------- +# +# TO-DO-14: Display details of the health endpoint +# - Set appropriate property of the "health" endpoint so that health details +# are always displayed +# - Let the application restart (via Spring Boot devtools), verify +# health details get displayed + +# ---------------------------------------------------- +# +# TO-DO-20: Organize health indicators into groups +# - Create 3 groups: "system", "web", and "application" as following: +# - The "system" group includes "diskSpace" and "db" health indicators +# - The "web" group includes "ping" health indicator +# - The "application" group includes "restaurantHealthCheck" health indicator +# - For "system" and "application" groups, configure "show-details" with "always" +# - Remove "management.endpoint.health.show-details=always" you added earlier +# - Restart the application and access health indicator of each group diff --git a/lab/44-actuator/src/main/resources/data-no-restaurants.sql b/lab/44-actuator/src/main/resources/data-no-restaurants.sql new file mode 100644 index 0000000..a5f0dce --- /dev/null +++ b/lab/44-actuator/src/main/resources/data-no-restaurants.sql @@ -0,0 +1,76 @@ +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (17, 'Daniel', 1.0, 0.00); + +-- insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) values ('1234567894', 'AppleBees1', .08, 'A'); \ No newline at end of file diff --git a/lab/44-actuator/src/main/resources/data-with-restaurants.sql b/lab/44-actuator/src/main/resources/data-with-restaurants.sql new file mode 100644 index 0000000..88e41a8 --- /dev/null +++ b/lab/44-actuator/src/main/resources/data-with-restaurants.sql @@ -0,0 +1,76 @@ +insert into T_ACCOUNT (NUMBER, NAME) values ('123456789', 'Keith and Keri Donald'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456001', 'Dollie R. Adams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456002', 'Cornelia J. Andresen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456003', 'Coral Villareal Betancourt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456004', 'Chad I. Cobbs'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456005', 'Michael C. Feller'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456006', 'Michael J. Grover'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456007', 'John C. Howard'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456008', 'Ida Ketterer'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456009', 'Laina Ochoa Lucero'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456010', 'Wesley M. Mayo'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456011', 'Leslie F. Mcclary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456012', 'John D. Mudra'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456013', 'Pietronella J. Nielsen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456014', 'John S. Oleary'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456015', 'Glenda D. Smith'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456016', 'Willemina O. Thygesen'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456017', 'Antje Vogt'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456018', 'Julia Weber'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456019', 'Mark T. Williams'); +insert into T_ACCOUNT (NUMBER, NAME) values ('123456020', 'Christine J. Wilson'); + +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (0, '1234123412341234'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (1, '1234123412340001'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (2, '1234123412340002'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (3, '1234123412340003'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (4, '1234123412340004'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (5, '1234123412340005'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (6, '1234123412340006'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (7, '1234123412340007'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (8, '1234123412340008'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (9, '1234123412340009'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (10, '1234123412340010'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (11, '1234123412340011'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (12, '1234123412340012'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (13, '1234123412340013'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (14, '1234123412340014'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (15, '1234123412340015'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (16, '1234123412340016'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (17, '1234123412340017'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (18, '1234123412340018'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (19, '1234123412340019'); +insert into T_ACCOUNT_CREDIT_CARD (ACCOUNT_ID, NUMBER) values (20, '1234123412340020'); + +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Annabelle', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (0, 'Corgan', .5, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Antolin', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argus', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Gian', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (3, 'Argeo', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kai', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Kasper', .33, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (8, 'Ernst', .34, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Brian', .75, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (12, 'Shelby', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Charles', .50, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Thomas', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (15, 'Neil', .25, 0.00); +insert into T_ACCOUNT_BENEFICIARY (ACCOUNT_ID, NAME, ALLOCATION_PERCENTAGE, SAVINGS) + values (17, 'Daniel', 1.0, 0.00); + +insert into T_RESTAURANT (MERCHANT_NUMBER, NAME, BENEFIT_PERCENTAGE, BENEFIT_AVAILABILITY_POLICY) values ('1234567894', 'AppleBees', .08, 'A'); \ No newline at end of file diff --git a/lab/44-actuator/src/main/resources/schema.sql b/lab/44-actuator/src/main/resources/schema.sql new file mode 100644 index 0000000..8845c34 --- /dev/null +++ b/lab/44-actuator/src/main/resources/schema.sql @@ -0,0 +1,20 @@ +drop table T_ACCOUNT_BENEFICIARY if exists; +drop table T_ACCOUNT_CREDIT_CARD if exists; +drop table T_ACCOUNT if exists; +drop table T_RESTAURANT if exists; +drop table T_REWARD if exists; +drop sequence S_REWARD_CONFIRMATION_NUMBER if exists; +drop table DUAL_REWARD_CONFIRMATION_NUMBER if exists; + +create table T_ACCOUNT (ID integer identity primary key, NUMBER varchar(9), NAME varchar(50) not null, unique(NUMBER)); +create table T_ACCOUNT_CREDIT_CARD (ID integer identity primary key, ACCOUNT_ID integer, NUMBER varchar(16), unique(ACCOUNT_ID, NUMBER)); +create table T_ACCOUNT_BENEFICIARY (ID integer identity primary key, ACCOUNT_ID integer, NAME varchar(50), ALLOCATION_PERCENTAGE decimal(3,2) not null, SAVINGS decimal(8,2) not null, unique(ACCOUNT_ID, NAME)); +create table T_RESTAURANT (ID integer identity primary key, MERCHANT_NUMBER varchar(10) not null, NAME varchar(80) not null, BENEFIT_PERCENTAGE decimal(3,2) not null, BENEFIT_AVAILABILITY_POLICY varchar(1) not null, unique(MERCHANT_NUMBER)); +create table T_REWARD (ID integer identity primary key, CONFIRMATION_NUMBER varchar(25) not null, REWARD_AMOUNT decimal(8,2) not null, REWARD_DATE date not null, ACCOUNT_NUMBER varchar(9) not null, DINING_AMOUNT decimal not null, DINING_MERCHANT_NUMBER varchar(10) not null, DINING_DATE date not null, unique(CONFIRMATION_NUMBER)); + +create sequence S_REWARD_CONFIRMATION_NUMBER start with 1; +create table DUAL_REWARD_CONFIRMATION_NUMBER (ZERO integer); +insert into DUAL_REWARD_CONFIRMATION_NUMBER values (0); + +alter table T_ACCOUNT_CREDIT_CARD add constraint FK_ACCOUNT_CREDIT_CARD foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; +alter table T_ACCOUNT_BENEFICIARY add constraint FK_ACCOUNT_BENEFICIARY foreign key (ACCOUNT_ID) references T_ACCOUNT(ID) on delete cascade; \ No newline at end of file diff --git a/lab/44-actuator/src/main/resources/static/README.md b/lab/44-actuator/src/main/resources/static/README.md new file mode 100644 index 0000000..0e64b37 --- /dev/null +++ b/lab/44-actuator/src/main/resources/static/README.md @@ -0,0 +1,7 @@ +# Spring Boot Resources + +By default, Spring Boot looks for resources in `classpath:static`, which is +this location. However `if src/main/webapp` exists it will look in there +first. + +Spring Boot is also conveniently configured to map '/' to `index.html`. \ No newline at end of file diff --git a/lab/44-actuator/src/main/resources/static/index.html b/lab/44-actuator/src/main/resources/static/index.html new file mode 100644 index 0000000..23a76ae --- /dev/null +++ b/lab/44-actuator/src/main/resources/static/index.html @@ -0,0 +1,69 @@ + + + + + + + + + + + actuator: Using Spring Boot Actuators + + + + +
+
+ +
+ +
+ +

actuators: Using Actuators

+ +

You may find these links helpful:

+ +

Actuators

+ + +

Metrics

+ + + +
+
+ + + diff --git a/lab/44-actuator/src/test/java/accounts/client/AccountClientSecurityTests.java b/lab/44-actuator/src/test/java/accounts/client/AccountClientSecurityTests.java new file mode 100644 index 0000000..4136285 --- /dev/null +++ b/lab/44-actuator/src/test/java/accounts/client/AccountClientSecurityTests.java @@ -0,0 +1,67 @@ +package accounts.client; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.*; + +// TODO-24: Run the tests checking security configuration for the actuator endpoints +// - Take some time to understand what each test is for +// - Remove @Disabled annotation from each test and run it +// - Make sure all tests pass +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +public class AccountClientSecurityTests { + + @Autowired + private TestRestTemplate restTemplate; + + @Test + @Disabled + public void any_user_can_access_health_endpoint() { + ResponseEntity responseEntity + = restTemplate.getForEntity("/actuator/health", String.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @Disabled + public void any_user_can_access_info_endpoint() { + ResponseEntity responseEntity + = restTemplate.getForEntity("/actuator/info", String.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @Disabled + public void any_user_cannot_access_conditions_endpoint() { + ResponseEntity responseEntity + = restTemplate.withBasicAuth("anyuser", "anyuser") + .getForEntity("/actuator/conditions", String.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @Disabled + public void user_in_ADMIN_role_can_access_conditions_endpoint() { + ResponseEntity responseEntity + = restTemplate.withBasicAuth("admin", "admin") + .getForEntity("/actuator/conditions", String.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @Disabled + public void user_in_ACTUATOR_role_cannot_access_conditions_endpoint() { + ResponseEntity responseEntity + = restTemplate.withBasicAuth("actuator", "actuator") + .getForEntity("/actuator/conditions", String.class); + assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + +} \ No newline at end of file diff --git a/lab/44-actuator/src/test/java/accounts/web/AccountControllerTests.java b/lab/44-actuator/src/test/java/accounts/web/AccountControllerTests.java new file mode 100644 index 0000000..4ed63ee --- /dev/null +++ b/lab/44-actuator/src/test/java/accounts/web/AccountControllerTests.java @@ -0,0 +1,134 @@ +package accounts.web; + +import accounts.internal.StubAccountManager; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import rewards.internal.account.Account; +import rewards.internal.account.Beneficiary; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * A JUnit test case testing the AccountController. + */ +public class AccountControllerTests { + + private AccountController controller; + private MeterRegistry registry; + private Counter counter; + + @BeforeEach + public void setUp() { + registry = mock(MeterRegistry.class); + counter = mock(Counter.class); + doReturn(counter).when(registry).counter(any(String.class), any(String.class), any(String.class)); + + // TODO-10: Fix compiler error + // - This constructor needs to pass MeterRegistry object as well + // - Run the tests (except the @Disabled ones) in this test class, they should pass + controller = new AccountController(new StubAccountManager()); + } + + @Test + // TODO-19: Test the actuator endpoints + // - Remove @Disabled annotation below + // - Run this test - it should pass + @Disabled + public void testHandleDetailsRequest() { + Account account = controller.accountDetails(0); + assertNotNull(account); + assertEquals(Long.valueOf(0), account.getEntityId()); + + verify(registry).counter("account.fetch", "type", "fromCode"); + verify(counter).increment(); + } + + @Test + public void testHandleSummaryRequest() { + List accounts = controller.accountSummary(); + assertNotNull(accounts); + assertEquals(1, accounts.size()); + assertEquals(Long.valueOf(0), accounts.get(0).getEntityId()); + } + + @Test + public void testCreateAccount() { + Account newAccount = new Account("11223344", "Test"); + + // ServletUriComponentsBuilder expects to find the HttpRequest in the + // current thread (Spring MVC does this for you). For our test, we need + // to add a mock request manually + setupFakeRequest("http://localhost/accounts"); + + HttpEntity result = controller.createAccount(newAccount); + assertNotNull(result); + + // See StubAccountManager.nextEntityId - initialized to 3 + assertEquals("http://localhost/accounts/3", result.getHeaders().getLocation().toString()); + } + + @Test + public void testGetBeneficiary() { + Beneficiary beneficiary = controller.getBeneficiary(0, "Corgan"); + assertNotNull(beneficiary); + assertEquals(Long.valueOf(1), beneficiary.getEntityId()); + } + + @Test + public void testAddBeneficiary() { + + // ServletUriComponentsBuilder expects to find the HttpRequest in the + // current thread (Spring MVC does this for you). For our test, we need + // to add a mock request manually + setupFakeRequest("http://localhost/accounts/0/beneficiaries"); + + HttpEntity result = controller.addBeneficiary(0L, "Test2"); + assertNotNull(result); + assertEquals("http://localhost/accounts/0/beneficiaries/Test2", result.getHeaders().getLocation().toString()); + } + + @Test + public void testDeleteBeneficiary() { + controller.removeBeneficiary(0L, "Corgan"); + } + + @Test + public void testDeleteBeneficiaryFail() { + assertThrows(IllegalArgumentException.class, () -> { + controller.removeBeneficiary(0L, "Fred"); + }); + } + + /** + * Add a mocked up HttpServletRequest to Spring's internal request-context + * holder. Normally the DispatcherServlet does this, but we must do it manually + * to run our test. + * + * @param url + * The URL we are creating the fake request for. + */ + private void setupFakeRequest(String url) { + String requestURI = url.substring(16); // Drop "http://localhost" + + // We can use Spring's convenient mock implementation. Defaults to + // localhost in the URL. Since we only need the URL, we don't need + // to setup anything else in the request. + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestURI); + + // Puts the fake request in the current thread for the + // ServletUriComponentsBuilder to initialize itself from later. + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + +} diff --git a/lab/44-actuator/src/test/java/accounts/web/RestaurantHealthCheckTest.java b/lab/44-actuator/src/test/java/accounts/web/RestaurantHealthCheckTest.java new file mode 100644 index 0000000..b60294e --- /dev/null +++ b/lab/44-actuator/src/test/java/accounts/web/RestaurantHealthCheckTest.java @@ -0,0 +1,62 @@ +package accounts.web; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.Status; +import rewards.internal.restaurant.JpaRestaurantRepository; +import rewards.internal.restaurant.RestaurantRepository; + +import static org.mockito.Mockito.*; + +/* Modify this class to test the RestaurantHealthCheck class. + * The RestaurantHealthCheck class will implement HealthCheck + * so a health() method will exist - use it in the tests. + * Code will not compile until the next step. + */ +public class RestaurantHealthCheckTest { + private RestaurantHealthCheck restaurantHealthCheck; + private RestaurantRepository restaurantRepository; + + @BeforeEach + public void setUp() { + restaurantRepository = mock(JpaRestaurantRepository.class); + + // TODO-16b: Test custom health indicator + // - Create an instance of RestaurantHealthCheck class + // - Remove the two @Disabled annotations below + // - Run the test, make sure it passes. + restaurantHealthCheck = null; + } + + @Test + @Disabled + public void testHealthReturnsUpIfThereAreRestaurants() { + // Mock the Repository so getRestaurantCount returns 1 + doReturn(1L).when(restaurantRepository).getRestaurantCount(); + + // TODO-15a: Invoke the health() method on RestaurantHealthCheck object + // (You will write health() method in the next step) + Health result = null; + + // Health check should return UP + verify(restaurantRepository).getRestaurantCount(); + assert (result.getStatus()).equals(Status.UP); + } + + @Test + @Disabled + public void testHealthReturnsDownIfThereAreNoRestaurants() { + // Mock the Repository so getRestaurantCount returns 0 + doReturn(0L).when(restaurantRepository).getRestaurantCount(); + + // TODO-15b: Invoke the health() method on RestaurantHealthCheck object + // (You will write health() method in the next step) + Health result = null; + + // Health check should return DOWN + verify(restaurantRepository).getRestaurantCount(); + assert (result.getStatus()).equals(Status.DOWN); + } +} \ No newline at end of file diff --git a/lab/build.gradle b/lab/build.gradle new file mode 100644 index 0000000..ac8b10f --- /dev/null +++ b/lab/build.gradle @@ -0,0 +1,60 @@ +buildscript { + ext { + springBootVersion = "3.3.1" + easyMockVersion = "5.2" + jmonVersion = "2.82" + } + + repositories { + mavenCentral() + } +} + +plugins { + id 'org.springframework.boot' version "$springBootVersion" +} + +allprojects { + apply plugin: 'maven-publish' + + group = 'io.spring.training.core-spring' + version = '3.3.1' +} + +subprojects { + + apply plugin: 'java' + apply plugin: "io.spring.dependency-management" + + repositories { + mavenCentral() + } + + dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:$springBootVersion") + } + } + + dependencies { + implementation "org.springframework.boot:spring-boot-starter" + implementation "org.springframework.boot:spring-boot-starter-jdbc" + implementation "org.hsqldb:hsqldb" + + testImplementation "org.springframework.boot:spring-boot-starter-test" + } + + java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } + } + + tasks.withType(JavaCompile) { + options.encoding = 'UTF-8' + } + + tasks.named('test') { + useJUnitPlatform() + } +} diff --git a/lab/gradle/wrapper/gradle-wrapper.jar b/lab/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/lab/gradle/wrapper/gradle-wrapper.jar differ diff --git a/lab/gradle/wrapper/gradle-wrapper.properties b/lab/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..0d18421 --- /dev/null +++ b/lab/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/lab/gradlew b/lab/gradlew new file mode 100755 index 0000000..a69d9cb --- /dev/null +++ b/lab/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/lab/gradlew.bat b/lab/gradlew.bat new file mode 100644 index 0000000..53a6b23 --- /dev/null +++ b/lab/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lab/mvnw b/lab/mvnw new file mode 100755 index 0000000..19529dd --- /dev/null +++ b/lab/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/lab/mvnw.cmd b/lab/mvnw.cmd new file mode 100644 index 0000000..249bdf3 --- /dev/null +++ b/lab/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/lab/pom.xml b/lab/pom.xml new file mode 100644 index 0000000..94131c2 --- /dev/null +++ b/lab/pom.xml @@ -0,0 +1,210 @@ + + 4.0.0 + io.spring.training.core-spring + parentProject + 3.3.1 + + Spring Training + https://spring.io/training + + pom + + org.springframework.boot + spring-boot-starter-parent + 3.3.1 + + + + 5.2.0 + 3.5.6-Final + 2.82 + + 17 + + + 2.3.0 + + + 3.1.1 + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.springframework + spring-jdbc + + + + + org.hsqldb + hsqldb + + + + + + + + + + io.spring.training.core-spring + 00-rewards-common + ${project.version} + + + io.spring.training.core-spring + 01-rewards-db + ${project.version} + + + + org.hibernate + hibernate-jmx + ${hibernate.jmx.version} + + + org.easymock + easymock + ${easymock.version} + + + com.jamonapi + jamon + ${jamon.version} + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.maven.plugin} + + + + + + + + ${project.basedir}/src/main/resources + + **/* + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + + + + + org.apache.maven.plugins + maven-eclipse-plugin + 2.10 + + true + true + 2.0 + + **/*.* + + + + org.springframework.ide.eclipse.core.springnature + org.eclipse.jdt.core.javanature + org.eclipse.wst.common.project.facet.core.nature + org.eclipse.wst.common.modulecore.ModuleCoreNature + org.eclipse.jem.workbench.JavaEMFNature + + + org.springframework.ide.eclipse.core.springbuilder + + + + + maven-surefire-plugin + + + **/*Tests.java + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + + + + + + 00-rewards-common + 01-rewards-db + + + + 10-spring-intro + 10-spring-intro-solution + 12-javaconfig-dependency-injection + 12-javaconfig-dependency-injection-solution + 16-annotations + 16-annotations-solution + + + 22-aop + 22-aop-solution + 24-test + 24-test-solution + 26-jdbc + 26-jdbc-solution + 28-transactions + 28-transactions-solution + + + + + 30-jdbc-boot-solution + 32-jdbc-autoconfig + 32-jdbc-autoconfig-solution + 34-spring-data-jpa + 34-spring-data-jpa-solution + 36-mvc + 36-mvc-solution + + + 38-rest-ws + 38-rest-ws-solution + 40-boot-test + 40-boot-test-solution + 42-security-rest + 42-security-rest-solution + 44-actuator + 44-actuator-solution + + diff --git a/lab/settings.gradle b/lab/settings.gradle new file mode 100644 index 0000000..3b78211 --- /dev/null +++ b/lab/settings.gradle @@ -0,0 +1,36 @@ +/* + * This file was generated by the Gradle 'init' task. + */ + +rootProject.name = 'parentProject' +include(':00-rewards-common') +include(':01-rewards-db') +include(':10-spring-intro') +include(':10-spring-intro-solution') +include(':12-javaconfig-dependency-injection') +include(':12-javaconfig-dependency-injection-solution') +include(':16-annotations') +include(':16-annotations-solution') +include(':22-aop') +include(':22-aop-solution') +include(':24-test') +include(':24-test-solution') +include(':26-jdbc') +include(':26-jdbc-solution') +include(':28-transactions') +include(':28-transactions-solution') +include(':30-jdbc-boot-solution') +include(':32-jdbc-autoconfig') +include(':32-jdbc-autoconfig-solution') +include(':34-spring-data-jpa') +include(':34-spring-data-jpa-solution') +include(':36-mvc') +include(':36-mvc-solution') +include(':38-rest-ws') +include(':38-rest-ws-solution') +include(':40-boot-test') +include(':40-boot-test-solution') +include(':42-security-rest') +include(':42-security-rest-solution') +include(':44-actuator') +include(':44-actuator-solution')