diff --git a/ctms/schemas/contact.py b/ctms/schemas/contact.py index 200dd34a..8085d945 100644 --- a/ctms/schemas/contact.py +++ b/ctms/schemas/contact.py @@ -16,7 +16,12 @@ ) from .fxa import FirefoxAccountsInSchema, FirefoxAccountsSchema from .mofo import MozillaFoundationInSchema, MozillaFoundationSchema -from .newsletter import NewsletterInSchema, NewsletterSchema, NewsletterTableSchema +from .newsletter import ( + NewsletterInSchema, + NewsletterSchema, + NewsletterTableSchema, + NewsletterTimestampedSchema, +) from .product import ProductBaseSchema, ProductSegmentEnum from .waitlist import ( RelayWaitlistInSchema, @@ -26,6 +31,7 @@ WaitlistInSchema, WaitlistSchema, WaitlistTableSchema, + WaitlistTimestampedSchema, validate_waitlist_newsletters, ) @@ -360,8 +366,8 @@ class CTMSResponse(BaseModel): email: EmailSchema fxa: FirefoxAccountsSchema mofo: MozillaFoundationSchema - newsletters: List[NewsletterSchema] - waitlists: List[WaitlistSchema] + newsletters: List[NewsletterTimestampedSchema] + waitlists: List[WaitlistTimestampedSchema] # Retro-compat fields vpn_waitlist: VpnWaitlistSchema relay_waitlist: RelayWaitlistSchema diff --git a/ctms/schemas/newsletter.py b/ctms/schemas/newsletter.py index 85308a37..be67cc29 100644 --- a/ctms/schemas/newsletter.py +++ b/ctms/schemas/newsletter.py @@ -50,11 +50,7 @@ class Config: NewsletterSchema = NewsletterBase -class NewsletterTableSchema(NewsletterBase): - email_id: UUID4 = Field( - description=EMAIL_ID_DESCRIPTION, - example=EMAIL_ID_EXAMPLE, - ) +class NewsletterTimestampedSchema(NewsletterBase): create_timestamp: datetime = Field( description="Newsletter data creation timestamp", example="2020-12-05T19:21:50.908000+00:00", @@ -64,5 +60,12 @@ class NewsletterTableSchema(NewsletterBase): example="2021-02-04T15:36:57.511000+00:00", ) + +class NewsletterTableSchema(NewsletterTimestampedSchema): + email_id: UUID4 = Field( + description=EMAIL_ID_DESCRIPTION, + example=EMAIL_ID_EXAMPLE, + ) + class Config: extra = "forbid" diff --git a/ctms/schemas/waitlist.py b/ctms/schemas/waitlist.py index ed73ea71..5719ada0 100644 --- a/ctms/schemas/waitlist.py +++ b/ctms/schemas/waitlist.py @@ -60,11 +60,7 @@ class Config: WaitlistInSchema = WaitlistBase -class WaitlistTableSchema(WaitlistBase): - email_id: UUID4 = Field( - description=EMAIL_ID_DESCRIPTION, - example=EMAIL_ID_EXAMPLE, - ) +class WaitlistTimestampedSchema(WaitlistBase): create_timestamp: datetime = Field( description="Waitlist data creation timestamp", example="2020-12-05T19:21:50.908000+00:00", @@ -74,6 +70,13 @@ class WaitlistTableSchema(WaitlistBase): example="2021-02-04T15:36:57.511000+00:00", ) + +class WaitlistTableSchema(WaitlistTimestampedSchema): + email_id: UUID4 = Field( + description=EMAIL_ID_DESCRIPTION, + example=EMAIL_ID_EXAMPLE, + ) + class Config: extra = "forbid" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..cc7167d2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,52 @@ +""" +Common test configuration both unit and integration tests. +""" + +from datetime import datetime + + +class FuzzyAssert: + """ + This class is a testing helper that provides flexible equality + of values. + + .. code-block:: + + >>> FuzzyAssert(lambda x: x.startswith("a")) == "abc" + True + >>> FuzzyAssert(lambda x: x % 2 == 0) == 11 + False + + It is mainly used to make sure fields contain valid dates + without having to hardcode values: + + .. code-block:: + + >>> FuzzyAssert.iso8601() == "2020-01-01" + True + >>> FuzzyAssert.iso8601() == None + False + """ + + def __init__(self, test=lambda x: True, name="unnamed"): + self.test = test + self.name = name + + def __eq__(self, other): + return self.test(other) + + def __repr__(self): + return f"<{self.__class__.__name__}.{self.name}>" + + @classmethod + def iso8601(cls): + def is_iso8601_date(sdate): + if not isinstance(sdate, str): + return False + try: + datetime.fromisoformat(sdate) + return True + except ValueError: + return False + + return cls(is_iso8601_date, name="datetime") diff --git a/tests/integration/test_basket_waitlist_subscription.py b/tests/integration/test_basket_waitlist_subscription.py index a0bd834d..c0ad5eaf 100644 --- a/tests/integration/test_basket_waitlist_subscription.py +++ b/tests/integration/test_basket_waitlist_subscription.py @@ -7,6 +7,8 @@ import requests from pydantic import BaseSettings +from tests.conftest import FuzzyAssert + TEST_FOLDER = os.path.dirname(os.path.realpath(__file__)) @@ -137,6 +139,8 @@ def fetch_created(): }, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), } ] # Legacy (read-only) fields. @@ -173,6 +177,8 @@ def fetch_created(): }, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), } ] # Legacy (read-only) fields. @@ -231,6 +237,8 @@ def fetch_created(): }, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), } ] # Legacy (read-only) fields. @@ -265,6 +273,8 @@ def check_subscribed(): }, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), }, { "name": "relay-vpn-bundle", @@ -274,6 +284,8 @@ def check_subscribed(): }, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), }, ] # Legacy (read-only) fields. @@ -305,6 +317,8 @@ def check_unsubscribed(): "source": "https://relay.firefox.com/", "subscribed": False, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), }, { "name": "relay-vpn-bundle", @@ -314,6 +328,8 @@ def check_unsubscribed(): }, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), }, ] # Legacy (read-only) fields. diff --git a/tests/unit/routers/contacts/test_api_get.py b/tests/unit/routers/contacts/test_api_get.py index 9bdefebd..8289d67b 100644 --- a/tests/unit/routers/contacts/test_api_get.py +++ b/tests/unit/routers/contacts/test_api_get.py @@ -67,6 +67,8 @@ def test_get_ctms_for_minimal_contact(client, dbsession, email_factory): "source": newsletter.source, "subscribed": newsletter.subscribed, "unsub_reason": newsletter.unsub_reason, + "create_timestamp": newsletter.create_timestamp.isoformat(), + "update_timestamp": newsletter.update_timestamp.isoformat(), } ], "status": "ok", @@ -133,6 +135,8 @@ def test_get_ctms_for_maximal_contact(client, maximal_contact): "source": "https://www.mozilla.org/en-US/contribute/studentambassadors/", "subscribed": False, "unsub_reason": "Graduated, don't have time for FSA", + "create_timestamp": "2010-01-01T08:04:00+00:00", + "update_timestamp": "2020-01-28T14:50:00+00:00", }, { "format": "T", @@ -141,6 +145,8 @@ def test_get_ctms_for_maximal_contact(client, maximal_contact): "source": "https://commonvoice.mozilla.org/fr", "subscribed": True, "unsub_reason": None, + "create_timestamp": "2010-01-01T08:04:00+00:00", + "update_timestamp": "2020-01-28T14:50:00+00:00", }, { "format": "H", @@ -149,6 +155,8 @@ def test_get_ctms_for_maximal_contact(client, maximal_contact): "source": "https://www.mozilla.org/fr/firefox/accounts/", "subscribed": False, "unsub_reason": "done with this mailing list", + "create_timestamp": "2010-01-01T08:04:00+00:00", + "update_timestamp": "2020-01-28T14:50:00+00:00", }, { "format": "H", @@ -157,6 +165,8 @@ def test_get_ctms_for_maximal_contact(client, maximal_contact): "source": None, "subscribed": True, "unsub_reason": None, + "create_timestamp": "2010-01-01T08:04:00+00:00", + "update_timestamp": "2020-01-28T14:50:00+00:00", }, { "format": "H", @@ -165,6 +175,8 @@ def test_get_ctms_for_maximal_contact(client, maximal_contact): "source": None, "subscribed": True, "unsub_reason": None, + "create_timestamp": "2010-01-01T08:04:00+00:00", + "update_timestamp": "2020-01-28T14:50:00+00:00", }, { "format": "H", @@ -173,6 +185,8 @@ def test_get_ctms_for_maximal_contact(client, maximal_contact): "source": None, "subscribed": True, "unsub_reason": None, + "create_timestamp": "2010-01-01T08:04:00+00:00", + "update_timestamp": "2020-01-28T14:50:00+00:00", }, { "format": "H", @@ -181,6 +195,8 @@ def test_get_ctms_for_maximal_contact(client, maximal_contact): "source": None, "subscribed": True, "unsub_reason": None, + "create_timestamp": "2010-01-01T08:04:00+00:00", + "update_timestamp": "2020-01-28T14:50:00+00:00", }, ], "status": "ok", @@ -193,6 +209,8 @@ def test_get_ctms_for_maximal_contact(client, maximal_contact): "source": "https://a-software.mozilla.org/", "subscribed": True, "unsub_reason": None, + "create_timestamp": "2010-01-01T08:04:00+00:00", + "update_timestamp": "2020-01-28T14:50:00+00:00", }, { "fields": {"geo": "cn"}, @@ -200,6 +218,8 @@ def test_get_ctms_for_maximal_contact(client, maximal_contact): "source": None, "subscribed": True, "unsub_reason": None, + "create_timestamp": "2010-01-01T08:04:00+00:00", + "update_timestamp": "2020-01-28T14:50:00+00:00", }, { "fields": {"geo": "fr", "platform": "win64"}, @@ -207,6 +227,8 @@ def test_get_ctms_for_maximal_contact(client, maximal_contact): "source": "https://super-product.mozilla.org/", "subscribed": True, "unsub_reason": None, + "create_timestamp": "2010-01-01T08:04:00+00:00", + "update_timestamp": "2020-01-28T14:50:00+00:00", }, { "fields": {"geo": "ca", "platform": "windows,android"}, @@ -214,6 +236,8 @@ def test_get_ctms_for_maximal_contact(client, maximal_contact): "source": None, "subscribed": True, "unsub_reason": None, + "create_timestamp": "2010-01-01T08:04:00+00:00", + "update_timestamp": "2020-01-28T14:50:00+00:00", }, ], } @@ -277,6 +301,8 @@ def test_get_ctms_for_api_example(client, example_contact): "source": None, "subscribed": True, "unsub_reason": None, + "create_timestamp": "2020-12-05T19:21:50.908000+00:00", + "update_timestamp": "2021-02-04T15:36:57.511000+00:00", }, { "format": "H", @@ -285,6 +311,8 @@ def test_get_ctms_for_api_example(client, example_contact): "source": None, "subscribed": True, "unsub_reason": None, + "create_timestamp": "2020-12-05T19:21:50.908000+00:00", + "update_timestamp": "2021-02-04T15:36:57.511000+00:00", }, ], "status": "ok", @@ -297,6 +325,8 @@ def test_get_ctms_for_api_example(client, example_contact): "source": None, "subscribed": True, "unsub_reason": None, + "create_timestamp": "2020-12-05T19:21:50.908000+00:00", + "update_timestamp": "2021-02-04T15:36:57.511000+00:00", }, { "fields": {"geo": "fr"}, @@ -304,6 +334,8 @@ def test_get_ctms_for_api_example(client, example_contact): "source": None, "subscribed": True, "unsub_reason": None, + "create_timestamp": "2020-12-05T19:21:50.908000+00:00", + "update_timestamp": "2021-02-04T15:36:57.511000+00:00", }, { "fields": {"geo": "fr", "platform": "ios,mac"}, @@ -311,6 +343,8 @@ def test_get_ctms_for_api_example(client, example_contact): "source": None, "subscribed": True, "unsub_reason": None, + "create_timestamp": "2020-12-05T19:21:50.908000+00:00", + "update_timestamp": "2021-02-04T15:36:57.511000+00:00", }, ], } diff --git a/tests/unit/routers/contacts/test_api_patch.py b/tests/unit/routers/contacts/test_api_patch.py index d7eac6ad..85bed66c 100644 --- a/tests/unit/routers/contacts/test_api_patch.py +++ b/tests/unit/routers/contacts/test_api_patch.py @@ -17,6 +17,7 @@ MozillaFoundationSchema, ) from ctms.schemas.waitlist import WaitlistInSchema +from tests.conftest import FuzzyAssert from tests.unit.conftest import create_full_contact @@ -194,12 +195,8 @@ def test_patch_cannot_set_timestamps(client, maximal_contact): # `expected`. for newsletter in expected["newsletters"]: del newsletter["email_id"] - del newsletter["create_timestamp"] - del newsletter["update_timestamp"] for waitlist in expected["waitlists"]: del waitlist["email_id"] - del waitlist["create_timestamp"] - del waitlist["update_timestamp"] # products list is not (yet) in output schema assert expected["products"] == [] @@ -308,6 +305,8 @@ def test_patch_to_subscribe(client, maximal_contact): "source": None, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), } @@ -330,6 +329,8 @@ def test_patch_to_update_subscription(client, dbsession, newsletter_factory): "source": existing_newsletter.source, "subscribed": existing_newsletter.subscribed, "unsub_reason": existing_newsletter.unsub_reason, + "create_timestamp": existing_newsletter.create_timestamp.isoformat(), + "update_timestamp": existing_newsletter.update_timestamp.isoformat(), } @@ -360,6 +361,8 @@ def test_patch_to_unsubscribe(client, maximal_contact): "source": "https://commonvoice.mozilla.org/fr", "subscribed": False, "unsub_reason": "Too many emails.", + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), } @@ -472,6 +475,8 @@ def test_patch_to_add_a_waitlist(client, maximal_contact): "fields": {"geo": "es"}, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), } @@ -561,6 +566,8 @@ def test_patch_vpn_waitlist_legacy_add(client, minimal_contact): }, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), } ] @@ -607,6 +614,8 @@ def test_patch_vpn_waitlist_legacy_update(client, dbsession, waitlist_factory): "fields": {"geo": "it", "platform": None}, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), } ] @@ -631,6 +640,8 @@ def test_patch_vpn_waitlist_legacy_update_full(client, dbsession, waitlist_facto "fields": {"geo": "it", "platform": "linux"}, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), } ] @@ -641,6 +652,8 @@ def test_patch_relay_waitlist_legacy_add(client, minimal_contact): resp = client.patch(f"/ctms/{email_id}", json=patch_data, allow_redirects=True) assert resp.status_code == 200 actual = resp.json() + del actual["waitlists"][0]["create_timestamp"] + del actual["waitlists"][0]["update_timestamp"] assert actual["waitlists"] == [ { "name": "relay", @@ -694,6 +707,8 @@ def test_patch_relay_waitlist_legacy_update(client, dbsession, waitlist_factory) "fields": {"geo": "it"}, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), } ] @@ -743,6 +758,8 @@ def test_patch_relay_waitlist_legacy_update_all( "fields": {"geo": "it"}, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), }, { "name": "relay-vpn-bundle", @@ -750,6 +767,8 @@ def test_patch_relay_waitlist_legacy_update_all( "fields": {"geo": "it"}, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), }, ] @@ -772,6 +791,8 @@ def test_subscribe_to_relay_newsletter_turned_into_relay_waitlist( "fields": {"geo": "ru"}, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), }, ] @@ -825,6 +846,8 @@ def test_unsubscribe_from_relay_newsletter_removes_relay_waitlist( "source": None, "subscribed": True, "unsub_reason": None, + "create_timestamp": FuzzyAssert.iso8601(), + "update_timestamp": FuzzyAssert.iso8601(), } ] diff --git a/tests/unit/routers/contacts/test_bulk.py b/tests/unit/routers/contacts/test_bulk.py index 784776c9..0d60a2bf 100644 --- a/tests/unit/routers/contacts/test_bulk.py +++ b/tests/unit/routers/contacts/test_bulk.py @@ -121,12 +121,8 @@ def test_get_ctms_bulk_by_timerange( # The reponse does not show `email_id` and timestamp fields. for newsletter in dict_contact_expected["newsletters"]: del newsletter["email_id"] - del newsletter["create_timestamp"] - del newsletter["update_timestamp"] for waitlist in dict_contact_expected["waitlists"]: del waitlist["email_id"] - del waitlist["create_timestamp"] - del waitlist["update_timestamp"] assert dict_contact_expected == dict_contact_actual assert results["next"] is not None