diff --git a/core/src/main/java/google/registry/model/domain/GracePeriod.java b/core/src/main/java/google/registry/model/domain/GracePeriod.java
index dcc52e641..3f7c71f70 100644
--- a/core/src/main/java/google/registry/model/domain/GracePeriod.java
+++ b/core/src/main/java/google/registry/model/domain/GracePeriod.java
@@ -124,4 +124,16 @@ public class GracePeriod extends GracePeriodBase implements DatastoreAndSqlEntit
clone.restoreHistoryIds();
return clone;
}
+
+ /**
+ * Returns a clone of this {@link GracePeriod} with {@link #billingEventRecurring} set to the
+ * given value.
+ *
+ *
TODO(b/162231099): Remove this function after duplicate id issue is solved.
+ */
+ public GracePeriod cloneWithRecurringBillingEvent(VKey recurring) {
+ GracePeriod clone = clone(this);
+ clone.billingEventRecurring = recurring;
+ return clone;
+ }
}
diff --git a/core/src/main/java/google/registry/tools/ResaveEntitiesWithUniqueIdCommand.java b/core/src/main/java/google/registry/tools/DedupeEntityIdsCommand.java
similarity index 58%
rename from core/src/main/java/google/registry/tools/ResaveEntitiesWithUniqueIdCommand.java
rename to core/src/main/java/google/registry/tools/DedupeEntityIdsCommand.java
index 49ab9521b..97349f97a 100644
--- a/core/src/main/java/google/registry/tools/ResaveEntitiesWithUniqueIdCommand.java
+++ b/core/src/main/java/google/registry/tools/DedupeEntityIdsCommand.java
@@ -19,42 +19,22 @@ import static google.registry.model.ofy.ObjectifyService.ofy;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.beust.jcommander.Parameter;
-import com.beust.jcommander.Parameters;
import com.google.appengine.api.datastore.KeyFactory;
import com.google.common.base.Splitter;
import com.google.common.io.CharStreams;
import com.google.common.io.Files;
import com.googlecode.objectify.Key;
import google.registry.model.ImmutableObject;
-import google.registry.model.billing.BillingEvent;
import google.registry.model.domain.DomainBase;
import google.registry.util.NonFinalForTesting;
+import google.registry.util.TypeUtils.TypeInstantiator;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.List;
-/**
- * Command to resave entities with a unique id.
- *
- * This command is used to address the duplicate id issue we found for certain {@link
- * BillingEvent.OneTime} entities. The command reassigns an application wide unique id to the
- * problematic entity and resaves it, it also resaves the entity having reference to the problematic
- * entity with the updated id.
- *
- *
To use this command, you will need to provide the path to a file containing a list of strings
- * representing the literal of Objectify key for the problematic entities. An example key literal
- * is:
- *
- *
- * "DomainBase", "111111-TEST", "HistoryEntry", 2222222, "OneTime", 3333333
- *
- *
- * Note that the double quotes are part of the key literal. The key literal can be retrieved from
- * the column __key__.path in BigQuery.
- */
-@Parameters(separators = " =", commandDescription = "Resave entities with a unique id.")
-public class ResaveEntitiesWithUniqueIdCommand extends MutatingCommand {
+/** Base Command to dedupe entities with duplicate IDs. */
+abstract class DedupeEntityIdsCommand extends MutatingCommand {
@Parameter(
names = "--key_paths_file",
@@ -66,7 +46,9 @@ public class ResaveEntitiesWithUniqueIdCommand extends MutatingCommand {
@NonFinalForTesting private static InputStream stdin = System.in;
- private String keyChangeMessage;
+ private StringBuilder changeMessage = new StringBuilder();
+
+ abstract void dedupe(T entity);
@Override
protected void init() throws Exception {
@@ -85,8 +67,9 @@ public class ResaveEntitiesWithUniqueIdCommand extends MutatingCommand {
keyPathsFile == null ? "STDIN" : "File " + keyPathsFile.getAbsolutePath()));
continue;
}
- if (entity instanceof BillingEvent.OneTime) {
- resaveBillingEvent((BillingEvent.OneTime) entity);
+ Class clazz = new TypeInstantiator(getClass()) {}.getExactType();
+ if (clazz.isInstance(entity)) {
+ dedupe((T) entity);
} else {
throw new IllegalArgumentException("Unsupported entity key: " + untypedKey);
}
@@ -96,37 +79,19 @@ public class ResaveEntitiesWithUniqueIdCommand extends MutatingCommand {
@Override
protected void postBatchExecute() {
- System.out.println(keyChangeMessage);
+ System.out.println(changeMessage);
}
- private void deleteOldAndSaveNewEntity(ImmutableObject oldEntity, ImmutableObject newEntity) {
+ void stageEntityKeyChange(ImmutableObject oldEntity, ImmutableObject newEntity) {
stageEntityChange(oldEntity, null);
stageEntityChange(null, newEntity);
+ appendChangeMessage(
+ String.format(
+ "Changed entity key from: %s to: %s", Key.create(oldEntity), Key.create(newEntity)));
}
- private void resaveBillingEvent(BillingEvent.OneTime billingEvent) {
- Key key = Key.create(billingEvent);
- Key domainKey = getGrandParentAsDomain(key);
- DomainBase domain = ofy().load().key(domainKey).now();
-
- // The BillingEvent.OneTime entity to be resaved should be the billing event created a few
- // years ago, so they should not be referenced from TransferData and GracePeriod in the domain.
- assertNotInDomainTransferData(domain, key);
- domain
- .getGracePeriods()
- .forEach(
- gracePeriod ->
- checkState(
- !gracePeriod.getOneTimeBillingEvent().getOfyKey().equals(key),
- "Entity %s is referenced by a grace period in domain %s",
- key,
- domainKey));
-
- // By setting id to 0L, Buildable.build() will assign an application wide unique id to it.
- BillingEvent.OneTime uniqIdBillingEvent = billingEvent.asBuilder().setId(0L).build();
- deleteOldAndSaveNewEntity(billingEvent, uniqIdBillingEvent);
- keyChangeMessage =
- String.format("Old Entity Key: %s New Entity Key: %s", key, Key.create(uniqIdBillingEvent));
+ void appendChangeMessage(String message) {
+ changeMessage.append(message);
}
private static boolean isKind(Key> key, Class> clazz) {
@@ -165,7 +130,7 @@ public class ResaveEntitiesWithUniqueIdCommand extends MutatingCommand {
return literal.substring(1, literal.length() - 1);
}
- private static Key getGrandParentAsDomain(Key> key) {
+ static Key getGrandParentAsDomain(Key> key) {
Key> grandParent;
try {
grandParent = key.getParent().getParent();
@@ -178,19 +143,4 @@ public class ResaveEntitiesWithUniqueIdCommand extends MutatingCommand {
}
return (Key) grandParent;
}
-
- private static void assertNotInDomainTransferData(DomainBase domainBase, Key> key) {
- if (!domainBase.getTransferData().isEmpty()) {
- domainBase
- .getTransferData()
- .getServerApproveEntities()
- .forEach(
- entityKey ->
- checkState(
- !entityKey.getOfyKey().equals(key),
- "Entity %s is referenced by the transfer data in domain %s",
- key,
- domainBase.createVKey().getOfyKey()));
- }
- }
}
diff --git a/core/src/main/java/google/registry/tools/DedupeOneTimeBillingEventIdsCommand.java b/core/src/main/java/google/registry/tools/DedupeOneTimeBillingEventIdsCommand.java
new file mode 100644
index 000000000..b827ef840
--- /dev/null
+++ b/core/src/main/java/google/registry/tools/DedupeOneTimeBillingEventIdsCommand.java
@@ -0,0 +1,88 @@
+// Copyright 2020 The Nomulus Authors. All Rights Reserved.
+//
+// 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 google.registry.tools;
+
+import static com.google.common.base.Preconditions.checkState;
+import static google.registry.model.ofy.ObjectifyService.ofy;
+
+import com.beust.jcommander.Parameters;
+import com.googlecode.objectify.Key;
+import google.registry.model.billing.BillingEvent;
+import google.registry.model.billing.BillingEvent.OneTime;
+import google.registry.model.domain.DomainBase;
+
+/**
+ * Command to dedupe {@link BillingEvent.OneTime} entities having duplicate IDs.
+ *
+ * This command is used to address the duplicate id issue we found for certain {@link
+ * BillingEvent.OneTime} entities. The command reassigns an application wide unique id to the
+ * problematic entity and resaves it, it also resaves the entity having reference to the problematic
+ * entity with the updated id.
+ *
+ *
To use this command, you will need to provide the path to a file containing a list of strings
+ * representing the literal of Objectify key for the problematic entities. An example key literal
+ * is:
+ *
+ *
+ * "DomainBase", "111111-TEST", "HistoryEntry", 2222222, "OneTime", 3333333
+ *
+ *
+ * Note that the double quotes are part of the key literal. The key literal can be retrieved from
+ * the column __key__.path in BigQuery.
+ */
+@Parameters(
+ separators = " =",
+ commandDescription = "Dedupe BillingEvent.OneTime entities with duplicate IDs.")
+public class DedupeOneTimeBillingEventIdsCommand extends DedupeEntityIdsCommand {
+
+ @Override
+ void dedupe(OneTime entity) {
+ Key key = Key.create(entity);
+ Key domainKey = getGrandParentAsDomain(key);
+ DomainBase domain = ofy().load().key(domainKey).now();
+
+ // The BillingEvent.OneTime entity to be resaved should be the billing event created a few
+ // years ago, so they should not be referenced from TransferData and GracePeriod in the domain.
+ assertNotInDomainTransferData(domain, key);
+ domain
+ .getGracePeriods()
+ .forEach(
+ gracePeriod ->
+ checkState(
+ !gracePeriod.getOneTimeBillingEvent().getOfyKey().equals(key),
+ "Entity %s is referenced by a grace period in domain %s",
+ key,
+ domainKey));
+
+ // By setting id to 0L, Buildable.build() will assign an application wide unique id to it.
+ BillingEvent.OneTime uniqIdBillingEvent = entity.asBuilder().setId(0L).build();
+ stageEntityKeyChange(entity, uniqIdBillingEvent);
+ }
+
+ private static void assertNotInDomainTransferData(DomainBase domainBase, Key> key) {
+ if (!domainBase.getTransferData().isEmpty()) {
+ domainBase
+ .getTransferData()
+ .getServerApproveEntities()
+ .forEach(
+ entityKey ->
+ checkState(
+ !entityKey.getOfyKey().equals(key),
+ "Entity %s is referenced by the transfer data in domain %s",
+ key,
+ domainBase.createVKey().getOfyKey()));
+ }
+ }
+}
diff --git a/core/src/main/java/google/registry/tools/DedupeRecurringBillingEventIdsCommand.java b/core/src/main/java/google/registry/tools/DedupeRecurringBillingEventIdsCommand.java
new file mode 100644
index 000000000..03721eed3
--- /dev/null
+++ b/core/src/main/java/google/registry/tools/DedupeRecurringBillingEventIdsCommand.java
@@ -0,0 +1,192 @@
+// Copyright 2020 The Nomulus Authors. All Rights Reserved.
+//
+// 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 google.registry.tools;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static google.registry.model.ofy.ObjectifyService.ofy;
+
+import com.beust.jcommander.Parameters;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.googlecode.objectify.Key;
+import google.registry.model.billing.BillingEvent;
+import google.registry.model.billing.BillingEvent.OneTime;
+import google.registry.model.billing.BillingEvent.Recurring;
+import google.registry.model.domain.DomainBase;
+import google.registry.model.domain.GracePeriod;
+import google.registry.model.transfer.DomainTransferData;
+import google.registry.model.transfer.TransferData.TransferServerApproveEntity;
+import google.registry.persistence.VKey;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * A command that re-saves the problematic {@link BillingEvent.Recurring} entities with unique IDs.
+ *
+ * This command is used to address the duplicate id issue we found for certain {@link
+ * BillingEvent.Recurring} entities. The command reassigns an application wide unique id to the
+ * problematic entity and resaves it, it also resaves the entity having reference to the problematic
+ * entity with the updated id.
+ *
+ *
To use this command, you will need to provide the path to a file containing a list of strings
+ * representing the literal of Objectify key for the problematic entities. An example key literal
+ * is:
+ *
+ *
+ * "DomainBase", "111111-TEST", "HistoryEntry", 2222222, "Recurring", 3333333
+ *
+ *
+ * Note that the double quotes are part of the key literal. The key literal can be retrieved from
+ * the column __key__.path in BigQuery.
+ */
+@Parameters(
+ separators = " =",
+ commandDescription = "Dedupe BillingEvent.Recurring entities with duplicate IDs.")
+public class DedupeRecurringBillingEventIdsCommand
+ extends DedupeEntityIdsCommand {
+
+ @Override
+ void dedupe(Recurring recurring) {
+ // Loads the associated DomainBase and BillingEvent.OneTime entities that
+ // may have reference to this BillingEvent.Recurring entity.
+ Key domainKey = getGrandParentAsDomain(Key.create(recurring));
+ DomainBase domain = ofy().load().key(domainKey).now();
+ List oneTimes =
+ ofy().load().type(BillingEvent.OneTime.class).ancestor(domainKey).list();
+
+ VKey oldRecurringVKey = recurring.createVKey();
+ // By setting id to 0L, Buildable.build() will assign an application wide unique id to it.
+ Recurring uniqIdRecurring = recurring.asBuilder().setId(0L).build();
+ VKey newRecurringVKey = uniqIdRecurring.createVKey();
+
+ // After having the unique id for the BillingEvent.Recurring entity, we also need to
+ // update the references in other entities to point to the new BillingEvent.Recurring
+ // entity.
+ updateReferenceInOneTimeBillingEvent(oneTimes, oldRecurringVKey, newRecurringVKey);
+ updateReferenceInDomain(domain, oldRecurringVKey, newRecurringVKey);
+
+ stageEntityKeyChange(recurring, uniqIdRecurring);
+ }
+
+ /**
+ * Resaves {@link BillingEvent.OneTime} entities with updated {@link
+ * BillingEvent.OneTime#cancellationMatchingBillingEvent}.
+ *
+ * {@link BillingEvent.OneTime#cancellationMatchingBillingEvent} is a {@link VKey} to a {@link
+ * BillingEvent.Recurring} entity. So, if the {@link BillingEvent.Recurring} entity gets a new key
+ * by changing its id, we need to update {@link
+ * BillingEvent.OneTime#cancellationMatchingBillingEvent} as well.
+ */
+ private void updateReferenceInOneTimeBillingEvent(
+ List oneTimes, VKey oldRecurringVKey, VKey newRecurringVKey) {
+ oneTimes.forEach(
+ oneTime -> {
+ if (oneTime.getCancellationMatchingBillingEvent() != null
+ && oneTime.getCancellationMatchingBillingEvent().equals(oldRecurringVKey)) {
+ BillingEvent.OneTime updatedOneTime =
+ oneTime.asBuilder().setCancellationMatchingBillingEvent(newRecurringVKey).build();
+ stageEntityChange(oneTime, updatedOneTime);
+ appendChangeMessage(
+ String.format(
+ "Changed cancellationMatchingBillingEvent in entity %s from %s to %s\n",
+ oneTime.createVKey().getOfyKey(),
+ oneTime.getCancellationMatchingBillingEvent().getOfyKey(),
+ updatedOneTime.getCancellationMatchingBillingEvent().getOfyKey()));
+ }
+ });
+ }
+
+ /**
+ * Resaves {@link DomainBase} entity with updated references to {@link BillingEvent.Recurring}
+ * entity.
+ *
+ * The following 4 fields in the domain entity can be or have a reference to this
+ * BillingEvent.Recurring entity, so we need to check them and replace them with the new entity
+ * when necessary:
+ *
+ *
+ * - domain.autorenewBillingEvent, see {@link DomainBase#autorenewBillingEvent}
+ *
- domain.transferData.serverApproveAutorenewEvent, see {@link
+ * DomainTransferData#serverApproveAutorenewEvent}
+ *
- domain.transferData.serverApproveEntities, see {@link
+ * DomainTransferData#serverApproveEntities}
+ *
- domain.gracePeriods.billingEventRecurring, see {@link GracePeriod#billingEventRecurring}
+ *
+ */
+ private void updateReferenceInDomain(
+ DomainBase domain, VKey oldRecurringVKey, VKey newRecurringVKey) {
+ DomainBase.Builder domainBuilder = domain.asBuilder();
+ StringBuilder domainChange =
+ new StringBuilder(
+ String.format(
+ "Resaved domain %s with following changes:\n", domain.createVKey().getOfyKey()));
+
+ if (domain.getAutorenewBillingEvent() != null
+ && domain.getAutorenewBillingEvent().equals(oldRecurringVKey)) {
+ domainBuilder.setAutorenewBillingEvent(newRecurringVKey);
+ domainChange.append(
+ String.format(
+ " Changed autorenewBillingEvent from %s to %s.\n",
+ oldRecurringVKey, newRecurringVKey));
+ }
+
+ if (domain.getTransferData().getServerApproveAutorenewEvent() != null
+ && domain.getTransferData().getServerApproveAutorenewEvent().equals(oldRecurringVKey)) {
+ Set> serverApproveEntities =
+ Sets.union(
+ Sets.difference(
+ domain.getTransferData().getServerApproveEntities(),
+ ImmutableSet.of(oldRecurringVKey)),
+ ImmutableSet.of(newRecurringVKey));
+ domainBuilder.setTransferData(
+ domain
+ .getTransferData()
+ .asBuilder()
+ .setServerApproveEntities(ImmutableSet.copyOf(serverApproveEntities))
+ .setServerApproveAutorenewEvent(newRecurringVKey)
+ .build());
+ domainChange.append(
+ String.format(
+ " Changed transferData.serverApproveAutoRenewEvent from %s to %s.\n",
+ oldRecurringVKey, newRecurringVKey));
+ domainChange.append(
+ String.format(
+ " Changed transferData.serverApproveEntities to remove %s and add %s.\n",
+ oldRecurringVKey, newRecurringVKey));
+ }
+
+ ImmutableSet updatedGracePeriod =
+ domain.getGracePeriods().stream()
+ .map(
+ gracePeriod ->
+ gracePeriod.getRecurringBillingEvent().equals(oldRecurringVKey)
+ ? gracePeriod.cloneWithRecurringBillingEvent(newRecurringVKey)
+ : gracePeriod)
+ .collect(toImmutableSet());
+ if (!updatedGracePeriod.equals(domain.getGracePeriods())) {
+ domainBuilder.setGracePeriods(updatedGracePeriod);
+ domainChange.append(
+ String.format(
+ " Changed gracePeriods to remove %s and add %s.\n",
+ oldRecurringVKey, newRecurringVKey));
+ }
+
+ DomainBase updatedDomain = domainBuilder.build();
+ if (!updatedDomain.equals(domain)) {
+ stageEntityChange(domain, updatedDomain);
+ appendChangeMessage(domainChange.toString());
+ }
+ }
+}
diff --git a/core/src/main/java/google/registry/tools/RegistryTool.java b/core/src/main/java/google/registry/tools/RegistryTool.java
index 3ef9f075a..0735caf8d 100644
--- a/core/src/main/java/google/registry/tools/RegistryTool.java
+++ b/core/src/main/java/google/registry/tools/RegistryTool.java
@@ -48,6 +48,8 @@ public final class RegistryTool {
.put("create_reserved_list", CreateReservedListCommand.class)
.put("create_tld", CreateTldCommand.class)
.put("curl", CurlCommand.class)
+ .put("dedupe_one_time_billing_event_ids", DedupeOneTimeBillingEventIdsCommand.class)
+ .put("dedupe_recurring_billing_event_ids", DedupeRecurringBillingEventIdsCommand.class)
.put("delete_allocation_tokens", DeleteAllocationTokensCommand.class)
.put("delete_domain", DeleteDomainCommand.class)
.put("delete_host", DeleteHostCommand.class)
@@ -99,7 +101,6 @@ public final class RegistryTool {
.put("remove_ip_address", RemoveIpAddressCommand.class)
.put("renew_domain", RenewDomainCommand.class)
.put("resave_entities", ResaveEntitiesCommand.class)
- .put("resave_entities_with_unique_id", ResaveEntitiesWithUniqueIdCommand.class)
.put("resave_environment_entities", ResaveEnvironmentEntitiesCommand.class)
.put("resave_epp_resource", ResaveEppResourceCommand.class)
.put("send_escrow_report_to_icann", SendEscrowReportToIcannCommand.class)
diff --git a/core/src/test/java/google/registry/tools/ResaveEntitiesWithUniqueIdCommandTest.java b/core/src/test/java/google/registry/tools/DedupeOneTimeBillingEventIdsCommandTest.java
similarity index 96%
rename from core/src/test/java/google/registry/tools/ResaveEntitiesWithUniqueIdCommandTest.java
rename to core/src/test/java/google/registry/tools/DedupeOneTimeBillingEventIdsCommandTest.java
index 5d1366f13..4ab27a7e3 100644
--- a/core/src/test/java/google/registry/tools/ResaveEntitiesWithUniqueIdCommandTest.java
+++ b/core/src/test/java/google/registry/tools/DedupeOneTimeBillingEventIdsCommandTest.java
@@ -38,9 +38,9 @@ import org.joda.money.Money;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-/** Unit tests for {@link ResaveEntitiesWithUniqueIdCommand}. */
-class ResaveEntitiesWithUniqueIdCommandTest
- extends CommandTestCase {
+/** Unit tests for {@link DedupeOneTimeBillingEventIdsCommand}. */
+class DedupeOneTimeBillingEventIdsCommandTest
+ extends CommandTestCase {
DomainBase domain;
HistoryEntry historyEntry;
@@ -48,7 +48,7 @@ class ResaveEntitiesWithUniqueIdCommandTest
BillingEvent.OneTime billingEventToResave;
@BeforeEach
- void setUp() {
+ void beforeEach() {
createTld("foobar");
domain = persistActiveDomain("foo.foobar");
historyEntry = persistHistoryEntry(domain);
diff --git a/core/src/test/java/google/registry/tools/DedupeRecurringBillingEventIdsCommandTest.java b/core/src/test/java/google/registry/tools/DedupeRecurringBillingEventIdsCommandTest.java
new file mode 100644
index 000000000..b8a03b170
--- /dev/null
+++ b/core/src/test/java/google/registry/tools/DedupeRecurringBillingEventIdsCommandTest.java
@@ -0,0 +1,262 @@
+// Copyright 2020 The Nomulus Authors. All Rights Reserved.
+//
+// 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 google.registry.tools;
+
+import static com.google.common.truth.Truth.assertThat;
+import static google.registry.model.ImmutableObjectSubject.assertAboutImmutableObjects;
+import static google.registry.model.ofy.ObjectifyService.ofy;
+import static google.registry.testing.DatastoreHelper.createTld;
+import static google.registry.testing.DatastoreHelper.persistActiveDomain;
+import static google.registry.testing.DatastoreHelper.persistResource;
+import static google.registry.util.DateTimeUtils.END_OF_TIME;
+import static org.joda.money.CurrencyUnit.USD;
+import static org.joda.time.DateTimeZone.UTC;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.googlecode.objectify.Key;
+import google.registry.model.ImmutableObject;
+import google.registry.model.billing.BillingEvent;
+import google.registry.model.billing.BillingEvent.Flag;
+import google.registry.model.billing.BillingEvent.Reason;
+import google.registry.model.domain.DomainBase;
+import google.registry.model.domain.GracePeriod;
+import google.registry.model.domain.rgp.GracePeriodStatus;
+import google.registry.model.reporting.HistoryEntry;
+import google.registry.model.transfer.DomainTransferData;
+import java.util.Arrays;
+import org.joda.money.Money;
+import org.joda.time.DateTime;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for {@link DedupeRecurringBillingEventIdsCommand}. */
+class DedupeRecurringBillingEventIdsCommandTest
+ extends CommandTestCase {
+
+ private final DateTime now = DateTime.now(UTC);
+ private DomainBase domain1;
+ private DomainBase domain2;
+ private HistoryEntry historyEntry1;
+ private HistoryEntry historyEntry2;
+ private BillingEvent.Recurring recurring1;
+ private BillingEvent.Recurring recurring2;
+
+ @BeforeEach
+ void beforeEach() {
+ createTld("tld");
+ domain1 = persistActiveDomain("foo.tld");
+ domain2 = persistActiveDomain("bar.tld");
+ historyEntry1 =
+ persistResource(
+ new HistoryEntry.Builder().setParent(domain1).setModificationTime(now).build());
+ historyEntry2 =
+ persistResource(
+ new HistoryEntry.Builder()
+ .setParent(domain2)
+ .setModificationTime(now.plusDays(1))
+ .build());
+ recurring1 =
+ persistResource(
+ new BillingEvent.Recurring.Builder()
+ .setParent(historyEntry1)
+ .setFlags(ImmutableSet.of(Flag.AUTO_RENEW))
+ .setReason(Reason.RENEW)
+ .setEventTime(now.plusYears(1))
+ .setRecurrenceEndTime(END_OF_TIME)
+ .setClientId("a registrar")
+ .setTargetId("foo.tld")
+ .build());
+ recurring2 =
+ persistResource(
+ new BillingEvent.Recurring.Builder()
+ .setId(recurring1.getId())
+ .setParent(historyEntry2)
+ .setFlags(ImmutableSet.of(Flag.AUTO_RENEW))
+ .setReason(Reason.RENEW)
+ .setEventTime(now.plusYears(1))
+ .setRecurrenceEndTime(END_OF_TIME)
+ .setClientId("a registrar")
+ .setTargetId("bar.tld")
+ .build());
+ }
+
+ @Test
+ void testOnlyResaveBillingEventsCorrectly() throws Exception {
+ assertThat(recurring1.getId()).isEqualTo(recurring2.getId());
+
+ runCommand(
+ "--force",
+ "--key_paths_file",
+ writeToNamedTmpFile("keypath.txt", getKeyPathLiteral(recurring1, recurring2)));
+
+ assertNotChangeExceptUpdateTime(domain1, domain2, historyEntry1, historyEntry2);
+ assertNotInDatastore(recurring1, recurring2);
+
+ ImmutableList recurrings = loadAllRecurrings();
+ assertThat(recurrings.size()).isEqualTo(2);
+
+ recurrings.forEach(
+ newRecurring -> {
+ if (newRecurring.getTargetId().equals("foo.tld")) {
+ assertSameRecurringEntityExceptId(newRecurring, recurring1);
+ } else if (newRecurring.getTargetId().equals("bar.tld")) {
+ assertSameRecurringEntityExceptId(newRecurring, recurring2);
+ } else {
+ fail("Unknown BillingEvent.Recurring entity: " + newRecurring.createVKey());
+ }
+ });
+ }
+
+ @Test
+ void testResaveAssociatedDomainAndOneTimeBillingEventCorrectly() throws Exception {
+ assertThat(recurring1.getId()).isEqualTo(recurring2.getId());
+ domain1 =
+ persistResource(
+ domain1
+ .asBuilder()
+ .setAutorenewBillingEvent(recurring1.createVKey())
+ .setGracePeriods(
+ ImmutableSet.of(
+ GracePeriod.createForRecurring(
+ GracePeriodStatus.AUTO_RENEW,
+ domain1.getRepoId(),
+ now.plusDays(45),
+ "a registrar",
+ recurring1.createVKey())))
+ .setTransferData(
+ new DomainTransferData.Builder()
+ .setServerApproveAutorenewEvent(recurring1.createVKey())
+ .setServerApproveEntities(ImmutableSet.of(recurring1.createVKey()))
+ .build())
+ .build());
+
+ BillingEvent.OneTime oneTime =
+ persistResource(
+ new BillingEvent.OneTime.Builder()
+ .setClientId("a registrar")
+ .setTargetId("foo.tld")
+ .setParent(historyEntry1)
+ .setReason(Reason.CREATE)
+ .setFlags(ImmutableSet.of(Flag.SYNTHETIC))
+ .setSyntheticCreationTime(now)
+ .setPeriodYears(2)
+ .setCost(Money.of(USD, 1))
+ .setEventTime(now)
+ .setBillingTime(now.plusDays(5))
+ .setCancellationMatchingBillingEvent(recurring1.createVKey())
+ .build());
+
+ runCommand(
+ "--force",
+ "--key_paths_file",
+ writeToNamedTmpFile("keypath.txt", getKeyPathLiteral(recurring1, recurring2)));
+
+ assertNotChangeExceptUpdateTime(domain2, historyEntry1, historyEntry2);
+ assertNotInDatastore(recurring1, recurring2);
+ ImmutableList recurrings = loadAllRecurrings();
+ assertThat(recurrings.size()).isEqualTo(2);
+
+ recurrings.forEach(
+ newRecurring -> {
+ if (newRecurring.getTargetId().equals("foo.tld")) {
+ assertSameRecurringEntityExceptId(newRecurring, recurring1);
+
+ BillingEvent.OneTime persistedOneTime = ofy().load().entity(oneTime).now();
+ assertAboutImmutableObjects()
+ .that(persistedOneTime)
+ .isEqualExceptFields(oneTime, "cancellationMatchingBillingEvent");
+ assertThat(persistedOneTime.getCancellationMatchingBillingEvent())
+ .isEqualTo(newRecurring.createVKey());
+
+ DomainBase persistedDomain = ofy().load().entity(domain1).now();
+ assertAboutImmutableObjects()
+ .that(persistedDomain)
+ .isEqualExceptFields(
+ domain1,
+ "updateTimestamp",
+ "revisions",
+ "gracePeriods",
+ "transferData",
+ "autorenewBillingEvent");
+ assertThat(persistedDomain.getAutorenewBillingEvent())
+ .isEqualTo(newRecurring.createVKey());
+ assertThat(persistedDomain.getGracePeriods())
+ .containsExactly(
+ GracePeriod.createForRecurring(
+ GracePeriodStatus.AUTO_RENEW,
+ domain1.getRepoId(),
+ now.plusDays(45),
+ "a registrar",
+ newRecurring.createVKey()));
+ assertThat(persistedDomain.getTransferData().getServerApproveAutorenewEvent())
+ .isEqualTo(newRecurring.createVKey());
+ assertThat(persistedDomain.getTransferData().getServerApproveEntities())
+ .containsExactly(newRecurring.createVKey());
+
+ } else if (newRecurring.getTargetId().equals("bar.tld")) {
+ assertSameRecurringEntityExceptId(newRecurring, recurring2);
+ } else {
+ fail("Unknown BillingEvent.Recurring entity: " + newRecurring.createVKey());
+ }
+ });
+ }
+
+ private static void assertNotInDatastore(ImmutableObject... entities) {
+ for (ImmutableObject entity : entities) {
+ assertThat(ofy().load().entity(entity).now()).isNull();
+ }
+ }
+
+ private static void assertNotChangeInDatastore(ImmutableObject... entities) {
+ for (ImmutableObject entity : entities) {
+ assertThat(ofy().load().entity(entity).now()).isEqualTo(entity);
+ }
+ }
+
+ private static void assertNotChangeExceptUpdateTime(ImmutableObject... entities) {
+ for (ImmutableObject entity : entities) {
+ assertAboutImmutableObjects()
+ .that(ofy().load().entity(entity).now())
+ .isEqualExceptFields(entity, "updateTimestamp", "revisions");
+ }
+ }
+
+ private static void assertSameRecurringEntityExceptId(
+ BillingEvent.Recurring recurring1, BillingEvent.Recurring recurring2) {
+ assertAboutImmutableObjects().that(recurring1).isEqualExceptFields(recurring2, "id");
+ }
+
+ private static ImmutableList loadAllRecurrings() {
+ return ImmutableList.copyOf(ofy().load().type(BillingEvent.Recurring.class));
+ }
+
+ private static String getKeyPathLiteral(Object... entities) {
+ return Arrays.stream(entities)
+ .map(
+ entity -> {
+ Key> key = Key.create(entity);
+ return String.format(
+ "\"DomainBase\", \"%s\", \"HistoryEntry\", %s, \"%s\", %s",
+ key.getParent().getParent().getName(),
+ key.getParent().getId(),
+ key.getKind(),
+ key.getId());
+ })
+ .reduce((k1, k2) -> k1 + "\n" + k2)
+ .get();
+ }
+}