From e3d400958c22b8cd4ade4912e9ca77bd06998c25 Mon Sep 17 00:00:00 2001 From: Shicong Huang Date: Tue, 3 Nov 2020 16:11:56 -0500 Subject: [PATCH] Add a command to fix duplicate id issue for BillingEvent.Recurring (#798) * Refactor ResaveEntitiesWithUniqueIdCommand to extract common methods * Add a command to dedupe BillingEvent.Recurring entities --- .../registry/model/domain/GracePeriod.java | 12 + ...mmand.java => DedupeEntityIdsCommand.java} | 84 ++---- .../DedupeOneTimeBillingEventIdsCommand.java | 88 ++++++ ...DedupeRecurringBillingEventIdsCommand.java | 192 +++++++++++++ .../google/registry/tools/RegistryTool.java | 3 +- ...upeOneTimeBillingEventIdsCommandTest.java} | 8 +- ...peRecurringBillingEventIdsCommandTest.java | 262 ++++++++++++++++++ 7 files changed, 577 insertions(+), 72 deletions(-) rename core/src/main/java/google/registry/tools/{ResaveEntitiesWithUniqueIdCommand.java => DedupeEntityIdsCommand.java} (58%) create mode 100644 core/src/main/java/google/registry/tools/DedupeOneTimeBillingEventIdsCommand.java create mode 100644 core/src/main/java/google/registry/tools/DedupeRecurringBillingEventIdsCommand.java rename core/src/test/java/google/registry/tools/{ResaveEntitiesWithUniqueIdCommandTest.java => DedupeOneTimeBillingEventIdsCommandTest.java} (96%) create mode 100644 core/src/test/java/google/registry/tools/DedupeRecurringBillingEventIdsCommandTest.java 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: + * + *

    + *
  1. domain.autorenewBillingEvent, see {@link DomainBase#autorenewBillingEvent} + *
  2. domain.transferData.serverApproveAutorenewEvent, see {@link + * DomainTransferData#serverApproveAutorenewEvent} + *
  3. domain.transferData.serverApproveEntities, see {@link + * DomainTransferData#serverApproveEntities} + *
  4. 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(); + } +}