Skip to content

Commit 9788cbb

Browse files
saurabhrbSaurabh Badenkal
andauthored
Add e2e relationship tests for pre-GA validation (microsoft#152)
## Summary Add end-to-end relationship tests that validate the full relationship API lifecycle against a live Dataverse environment. This is the primary pre-GA validation for Tim's relationship PRs (microsoft#88, microsoft#105, microsoft#114). ## Changes ### New: `tests/e2e/test_relationships_e2e.py` 11 curated e2e tests covering: | Test Class | Tests | Coverage | |---|---|---| | `TestOneToManyCore` | 1 | Full 1:N lifecycle: create, get, delete, field assertions | | `TestLookupField` | 1 | Convenience `create_lookup_field` to system table | | `TestManyToMany` | 2 | N:N lifecycle + nonexistent returns None | | `TestDataThroughRelationships` | 4 | `@odata.bind`, `$expand`, `$filter` on lookup, update binding | | `TestCascadeBehaviors` | 2 | Restrict blocks delete; Cascade deletes children | | `TestTypeDetection` | 1 | `get_relationship` distinguishes 1:N vs N:N | ### Updated: `examples/basic/functional_testing.py` - Added relationship testing section covering 1:N core API, convenience API, N:N, get, and delete - Added relationship imports and retry helpers ### Updated: `pyproject.toml` - Added `[tool.pytest.ini_options]` with `testpaths = ["tests/unit"]` - Default `pytest` runs only unit tests; e2e tests require explicit invocation ## How to run e2e tests ```bash # Set your Dataverse org URL export DATAVERSE_URL=https://yourorg.crm.dynamics.com # Run relationship e2e tests pytest tests/e2e/ -v -s ``` The tests authenticate via `InteractiveBrowserCredential` and create/delete temporary tables (prefixed `test_E2E*`). ## E2E Test Results (from `.scratch/` comprehensive suite) Ran 30 tests against `https://aurorabapenv71aff.crm10.dynamics.com`: - 25/30 passed on first run - 5 failures were test bugs (not SDK bugs), all fixed: - Metadata propagation timing (increased retries) - Navigation property name casing (`$expand` needs server-assigned nav prop) - `IsValidForAdvancedFind` requires `BooleanManagedProperty` complex type ## Finding: SDK inconsistency to address before GA `create_one_to_many_relationship()` returns `lookup_schema_name` as the user-provided SchemaName, but `$expand` requires the server-assigned `ReferencingEntityNavigationPropertyName` (which may differ in casing). The e2e tests work around this by calling `get_relationship()` after create to get the correct nav prop name. This should be harmonized before GA. ## Checklist - [x] 398 unit tests pass - [x] 11 e2e tests collected by pytest - [x] Default `pytest` excludes e2e (runs unit only) - [x] Code formatted with black - [x] Branch rebased on origin/main --------- Co-authored-by: Saurabh Badenkal <sbadenkal@microsoft.com>
1 parent 19e11c5 commit 9788cbb

4 files changed

Lines changed: 1055 additions & 0 deletions

File tree

examples/basic/functional_testing.py

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@
3232
# Import SDK components (assumes installation is already validated)
3333
from PowerPlatform.Dataverse.client import DataverseClient
3434
from PowerPlatform.Dataverse.core.errors import HttpError, MetadataError
35+
from PowerPlatform.Dataverse.models.relationship import (
36+
LookupAttributeMetadata,
37+
OneToManyRelationshipMetadata,
38+
ManyToManyRelationshipMetadata,
39+
CascadeConfiguration,
40+
)
41+
from PowerPlatform.Dataverse.models.labels import Label, LocalizedLabel
42+
from PowerPlatform.Dataverse.common.constants import (
43+
CASCADE_BEHAVIOR_NO_CASCADE,
44+
CASCADE_BEHAVIOR_REMOVE_LINK,
45+
)
3546
from azure.identity import InteractiveBrowserCredential
3647

3748

@@ -380,6 +391,274 @@ def cleanup_test_data(client: DataverseClient, table_info: Dict[str, Any], recor
380391
print("Test table kept for future testing")
381392

382393

394+
def backoff(op, *, delays=(0, 2, 5, 10, 20, 20)):
395+
"""Retry helper with exponential backoff for metadata propagation delays."""
396+
last = None
397+
total_delay = 0
398+
attempts = 0
399+
for d in delays:
400+
if d:
401+
time.sleep(d)
402+
total_delay += d
403+
attempts += 1
404+
try:
405+
result = op()
406+
if attempts > 1:
407+
print(f" * Backoff succeeded after {attempts - 1} retry(s); waited {total_delay}s total.")
408+
return result
409+
except Exception as ex:
410+
last = ex
411+
continue
412+
if last:
413+
if attempts:
414+
print(f" [WARN] Backoff exhausted after {max(attempts - 1, 0)} retry(s); waited {total_delay}s total.")
415+
raise last
416+
417+
418+
def test_relationships(client: DataverseClient) -> None:
419+
"""Test relationship lifecycle: create tables, 1:N, N:N, query, delete."""
420+
print("\n-> Relationship Tests")
421+
print("=" * 50)
422+
423+
rel_parent_schema = "test_RelParent"
424+
rel_child_schema = "test_RelChild"
425+
rel_m2m_schema = "test_RelProject"
426+
427+
# Track IDs for cleanup
428+
rel_id_1n = None
429+
rel_id_lookup = None
430+
rel_id_nn = None
431+
created_tables = []
432+
433+
try:
434+
# --- Cleanup any leftover resources from previous run ---
435+
print("Checking for leftover relationship test resources...")
436+
found_leftovers = False
437+
for rel_name in [
438+
"test_RelParent_RelChild",
439+
"contact_test_relchild_test_ManagerId",
440+
"test_relchild_relproject",
441+
]:
442+
try:
443+
rel = client.tables.get_relationship(rel_name)
444+
if rel:
445+
found_leftovers = True
446+
break
447+
except Exception:
448+
pass
449+
450+
if not found_leftovers:
451+
for tbl in [rel_child_schema, rel_parent_schema, rel_m2m_schema]:
452+
try:
453+
if client.tables.get(tbl):
454+
found_leftovers = True
455+
break
456+
except Exception:
457+
pass
458+
459+
if found_leftovers:
460+
cleanup_ok = input("Found leftover test resources. Clean up? (y/N): ").strip().lower() in ["y", "yes"]
461+
if cleanup_ok:
462+
for rel_name in [
463+
"test_RelParent_RelChild",
464+
"contact_test_relchild_test_ManagerId",
465+
"test_relchild_relproject",
466+
]:
467+
try:
468+
rel = client.tables.get_relationship(rel_name)
469+
if rel:
470+
client.tables.delete_relationship(rel.relationship_id)
471+
print(f" (Cleaned up relationship: {rel_name})")
472+
except Exception:
473+
pass
474+
475+
for tbl in [rel_child_schema, rel_parent_schema, rel_m2m_schema]:
476+
try:
477+
if client.tables.get(tbl):
478+
client.tables.delete(tbl)
479+
print(f" (Cleaned up table: {tbl})")
480+
except Exception:
481+
pass
482+
else:
483+
print("Skipping cleanup -- resources may conflict with new test run.")
484+
485+
# --- Create parent and child tables ---
486+
print("\nCreating relationship test tables...")
487+
488+
parent_info = backoff(
489+
lambda: client.tables.create(
490+
rel_parent_schema,
491+
{"test_Code": "string"},
492+
)
493+
)
494+
created_tables.append(rel_parent_schema)
495+
print(f"[OK] Created parent table: {parent_info['table_schema_name']}")
496+
497+
child_info = backoff(
498+
lambda: client.tables.create(
499+
rel_child_schema,
500+
{"test_Number": "string"},
501+
)
502+
)
503+
created_tables.append(rel_child_schema)
504+
print(f"[OK] Created child table: {child_info['table_schema_name']}")
505+
506+
proj_info = backoff(
507+
lambda: client.tables.create(
508+
rel_m2m_schema,
509+
{"test_ProjectCode": "string"},
510+
)
511+
)
512+
created_tables.append(rel_m2m_schema)
513+
print(f"[OK] Created M:N table: {proj_info['table_schema_name']}")
514+
515+
# --- Wait for table metadata to propagate ---
516+
wait_for_table_metadata(client, rel_parent_schema)
517+
wait_for_table_metadata(client, rel_child_schema)
518+
wait_for_table_metadata(client, rel_m2m_schema)
519+
520+
# --- Test 1: Create 1:N relationship (core API) ---
521+
print("\n Test 1: Create 1:N relationship (core API)")
522+
print(" " + "-" * 45)
523+
524+
lookup = LookupAttributeMetadata(
525+
schema_name="test_ParentId",
526+
display_name=Label(localized_labels=[LocalizedLabel(label="Parent", language_code=1033)]),
527+
required_level="None",
528+
)
529+
530+
relationship = OneToManyRelationshipMetadata(
531+
schema_name="test_RelParent_RelChild",
532+
referenced_entity=parent_info["table_logical_name"],
533+
referencing_entity=child_info["table_logical_name"],
534+
referenced_attribute=f"{parent_info['table_logical_name']}id",
535+
cascade_configuration=CascadeConfiguration(
536+
delete=CASCADE_BEHAVIOR_REMOVE_LINK,
537+
assign=CASCADE_BEHAVIOR_NO_CASCADE,
538+
merge=CASCADE_BEHAVIOR_NO_CASCADE,
539+
),
540+
)
541+
542+
result_1n = backoff(
543+
lambda: client.tables.create_one_to_many_relationship(
544+
lookup=lookup,
545+
relationship=relationship,
546+
)
547+
)
548+
549+
assert result_1n.relationship_schema_name == "test_RelParent_RelChild"
550+
assert result_1n.relationship_type == "one_to_many"
551+
assert result_1n.lookup_schema_name is not None
552+
rel_id_1n = result_1n.relationship_id
553+
print(f" [OK] Created 1:N relationship: {result_1n.relationship_schema_name}")
554+
print(f" Lookup: {result_1n.lookup_schema_name}")
555+
print(f" ID: {rel_id_1n}")
556+
557+
# --- Test 2: Create lookup field (convenience API) ---
558+
print("\n Test 2: Create lookup field (convenience API)")
559+
print(" " + "-" * 45)
560+
561+
result_lookup = backoff(
562+
lambda: client.tables.create_lookup_field(
563+
referencing_table=child_info["table_logical_name"],
564+
lookup_field_name="test_ManagerId",
565+
referenced_table="contact",
566+
display_name="Manager",
567+
description="The record's manager contact",
568+
required=False,
569+
cascade_delete=CASCADE_BEHAVIOR_REMOVE_LINK,
570+
)
571+
)
572+
573+
assert result_lookup.relationship_type == "one_to_many"
574+
assert result_lookup.lookup_schema_name is not None
575+
rel_id_lookup = result_lookup.relationship_id
576+
print(f" [OK] Created lookup: {result_lookup.lookup_schema_name}")
577+
print(f" Relationship: {result_lookup.relationship_schema_name}")
578+
579+
# --- Test 3: Create N:N relationship ---
580+
print("\n Test 3: Create N:N relationship")
581+
print(" " + "-" * 45)
582+
583+
m2m = ManyToManyRelationshipMetadata(
584+
schema_name="test_relchild_relproject",
585+
entity1_logical_name=child_info["table_logical_name"],
586+
entity2_logical_name=proj_info["table_logical_name"],
587+
)
588+
589+
result_nn = backoff(lambda: client.tables.create_many_to_many_relationship(relationship=m2m))
590+
591+
assert result_nn.relationship_schema_name == "test_relchild_relproject"
592+
assert result_nn.relationship_type == "many_to_many"
593+
rel_id_nn = result_nn.relationship_id
594+
print(f" [OK] Created N:N relationship: {result_nn.relationship_schema_name}")
595+
print(f" ID: {rel_id_nn}")
596+
597+
# --- Test 4: Get relationship metadata ---
598+
print("\n Test 4: Query relationship metadata")
599+
print(" " + "-" * 45)
600+
601+
fetched_1n = client.tables.get_relationship("test_RelParent_RelChild")
602+
assert fetched_1n is not None
603+
assert fetched_1n.relationship_type == "one_to_many"
604+
assert fetched_1n.relationship_id == rel_id_1n
605+
print(f" [OK] Retrieved 1:N: {fetched_1n.relationship_schema_name}")
606+
print(f" Referenced: {fetched_1n.referenced_entity}")
607+
print(f" Referencing: {fetched_1n.referencing_entity}")
608+
609+
fetched_nn = client.tables.get_relationship("test_relchild_relproject")
610+
assert fetched_nn is not None
611+
assert fetched_nn.relationship_type == "many_to_many"
612+
assert fetched_nn.relationship_id == rel_id_nn
613+
print(f" [OK] Retrieved N:N: {fetched_nn.relationship_schema_name}")
614+
print(f" Entity1: {fetched_nn.entity1_logical_name}")
615+
print(f" Entity2: {fetched_nn.entity2_logical_name}")
616+
617+
# Non-existent relationship should return None
618+
missing = client.tables.get_relationship("nonexistent_relationship_xyz")
619+
assert missing is None
620+
print(" [OK] Non-existent relationship returns None")
621+
622+
# --- Test 5: Delete relationships ---
623+
print("\n Test 5: Delete relationships")
624+
print(" " + "-" * 45)
625+
626+
backoff(lambda: client.tables.delete_relationship(rel_id_1n))
627+
rel_id_1n = None
628+
print(" [OK] Deleted 1:N relationship")
629+
630+
backoff(lambda: client.tables.delete_relationship(rel_id_lookup))
631+
rel_id_lookup = None
632+
print(" [OK] Deleted lookup relationship")
633+
634+
backoff(lambda: client.tables.delete_relationship(rel_id_nn))
635+
rel_id_nn = None
636+
print(" [OK] Deleted N:N relationship")
637+
638+
# Verify deletion
639+
verify = client.tables.get_relationship("test_RelParent_RelChild")
640+
assert verify is None
641+
print(" [OK] Verified 1:N deletion (get returns None)")
642+
643+
print("\n[OK] All relationship tests passed!")
644+
645+
finally:
646+
# Cleanup: delete any remaining relationships then tables
647+
for rid in [rel_id_1n, rel_id_lookup, rel_id_nn]:
648+
if rid:
649+
try:
650+
client.tables.delete_relationship(rid)
651+
except Exception:
652+
pass
653+
654+
for tbl in reversed(created_tables):
655+
try:
656+
backoff(lambda name=tbl: client.tables.delete(name))
657+
print(f" (Cleaned up table: {tbl})")
658+
except Exception as e:
659+
print(f" [WARN] Could not delete {tbl}: {e}")
660+
661+
383662
def _table_still_exists(client: DataverseClient, table_schema_name: Optional[str]) -> bool:
384663
if not table_schema_name:
385664
return False
@@ -403,6 +682,7 @@ def main():
403682
print(" - Table Creation & Metadata Operations")
404683
print(" - Record CRUD Operations")
405684
print(" - Query Functionality")
685+
print(" - Relationship Operations (1:N, N:N, lookup, get, delete)")
406686
print(" - Interactive Cleanup")
407687
print("=" * 70)
408688
print("For installation validation, run examples/basic/installation_example.py first")
@@ -422,6 +702,9 @@ def main():
422702
# Test querying
423703
test_query_records(client, table_info)
424704

705+
# Test relationships
706+
test_relationships(client)
707+
425708
# Success summary
426709
print("\nFunctional Test Summary")
427710
print("=" * 50)
@@ -430,6 +713,7 @@ def main():
430713
print("[OK] Record Creation: Success")
431714
print("[OK] Record Reading: Success")
432715
print("[OK] Record Querying: Success")
716+
print("[OK] Relationship Operations: Success")
433717
print("\nYour PowerPlatform Dataverse Client SDK is fully functional!")
434718

435719
# Cleanup

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,11 @@ select = [
9090
"UP", # pyupgrade
9191
"B", # flake8-bugbear
9292
]
93+
94+
[tool.pytest.ini_options]
95+
testpaths = ["tests/unit"]
96+
markers = [
97+
"e2e: end-to-end tests requiring a live Dataverse environment (DATAVERSE_URL)",
98+
]
99+
# e2e tests require a live Dataverse environment:
100+
# DATAVERSE_URL=https://yourorg.crm.dynamics.com pytest tests/e2e/ -v -s

tests/e2e/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.

0 commit comments

Comments
 (0)