Skip to content

Commit 08d55d0

Browse files
author
Saurabh Badenkal
committed
Add e2e relationship tests and integrate into functional testing
- Add tests/e2e/test_relationships_e2e.py with 11 curated e2e tests covering: - 1:N core API lifecycle (create, get, delete) - 1:N convenience API (create_lookup_field to system table) - N:N lifecycle and nonexistent returns None - Data through relationships (@odata.bind, $expand, $filter, update binding) - Cascade behaviors (Restrict blocks delete, Cascade deletes children) - Type detection (get_relationship distinguishes 1:N vs N:N) - Add relationship testing to examples/basic/functional_testing.py - Configure pytest to exclude e2e from default run (requires DATAVERSE_URL env var) - E2e tests run with: DATAVERSE_URL=https://yourorg.crm.dynamics.com pytest tests/e2e/ -v -s
1 parent eebee60 commit 08d55d0

4 files changed

Lines changed: 984 additions & 0 deletions

File tree

examples/basic/functional_testing.py

Lines changed: 256 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,246 @@ 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("Cleaning up previous relationship test resources...")
436+
for rel_name in [
437+
"test_RelParent_RelChild",
438+
"contact_test_relchild_test_ManagerId",
439+
"test_relchild_relproject",
440+
]:
441+
try:
442+
rel = client.tables.get_relationship(rel_name)
443+
if rel:
444+
client.tables.delete_relationship(rel.relationship_id)
445+
print(f" (Cleaned up relationship: {rel_name})")
446+
except Exception:
447+
pass
448+
449+
for tbl in [rel_child_schema, rel_parent_schema, rel_m2m_schema]:
450+
try:
451+
if client.tables.get(tbl):
452+
client.tables.delete(tbl)
453+
print(f" (Cleaned up table: {tbl})")
454+
except Exception:
455+
pass
456+
457+
# --- Create parent and child tables ---
458+
print("\nCreating relationship test tables...")
459+
460+
parent_info = backoff(
461+
lambda: client.tables.create(
462+
rel_parent_schema,
463+
{"test_Code": "string"},
464+
)
465+
)
466+
created_tables.append(rel_parent_schema)
467+
print(f"[OK] Created parent table: {parent_info['table_schema_name']}")
468+
469+
child_info = backoff(
470+
lambda: client.tables.create(
471+
rel_child_schema,
472+
{"test_Number": "string"},
473+
)
474+
)
475+
created_tables.append(rel_child_schema)
476+
print(f"[OK] Created child table: {child_info['table_schema_name']}")
477+
478+
proj_info = backoff(
479+
lambda: client.tables.create(
480+
rel_m2m_schema,
481+
{"test_ProjectCode": "string"},
482+
)
483+
)
484+
created_tables.append(rel_m2m_schema)
485+
print(f"[OK] Created M:N table: {proj_info['table_schema_name']}")
486+
487+
# --- Wait for table metadata to propagate ---
488+
wait_for_table_metadata(client, rel_parent_schema)
489+
wait_for_table_metadata(client, rel_child_schema)
490+
wait_for_table_metadata(client, rel_m2m_schema)
491+
492+
# --- Test 1: Create 1:N relationship (core API) ---
493+
print("\n Test 1: Create 1:N relationship (core API)")
494+
print(" " + "-" * 45)
495+
496+
lookup = LookupAttributeMetadata(
497+
schema_name="test_ParentId",
498+
display_name=Label(localized_labels=[LocalizedLabel(label="Parent", language_code=1033)]),
499+
required_level="None",
500+
)
501+
502+
relationship = OneToManyRelationshipMetadata(
503+
schema_name="test_RelParent_RelChild",
504+
referenced_entity=parent_info["table_logical_name"],
505+
referencing_entity=child_info["table_logical_name"],
506+
referenced_attribute=f"{parent_info['table_logical_name']}id",
507+
cascade_configuration=CascadeConfiguration(
508+
delete=CASCADE_BEHAVIOR_REMOVE_LINK,
509+
assign=CASCADE_BEHAVIOR_NO_CASCADE,
510+
merge=CASCADE_BEHAVIOR_NO_CASCADE,
511+
),
512+
)
513+
514+
result_1n = backoff(
515+
lambda: client.tables.create_one_to_many_relationship(
516+
lookup=lookup,
517+
relationship=relationship,
518+
)
519+
)
520+
521+
assert result_1n.relationship_schema_name == "test_RelParent_RelChild"
522+
assert result_1n.relationship_type == "one_to_many"
523+
assert result_1n.lookup_schema_name is not None
524+
rel_id_1n = result_1n.relationship_id
525+
print(f" [OK] Created 1:N relationship: {result_1n.relationship_schema_name}")
526+
print(f" Lookup: {result_1n.lookup_schema_name}")
527+
print(f" ID: {rel_id_1n}")
528+
529+
# --- Test 2: Create lookup field (convenience API) ---
530+
print("\n Test 2: Create lookup field (convenience API)")
531+
print(" " + "-" * 45)
532+
533+
result_lookup = backoff(
534+
lambda: client.tables.create_lookup_field(
535+
referencing_table=child_info["table_logical_name"],
536+
lookup_field_name="test_ManagerId",
537+
referenced_table="contact",
538+
display_name="Manager",
539+
description="The record's manager contact",
540+
required=False,
541+
cascade_delete=CASCADE_BEHAVIOR_REMOVE_LINK,
542+
)
543+
)
544+
545+
assert result_lookup.relationship_type == "one_to_many"
546+
assert result_lookup.lookup_schema_name is not None
547+
rel_id_lookup = result_lookup.relationship_id
548+
print(f" [OK] Created lookup: {result_lookup.lookup_schema_name}")
549+
print(f" Relationship: {result_lookup.relationship_schema_name}")
550+
551+
# --- Test 3: Create N:N relationship ---
552+
print("\n Test 3: Create N:N relationship")
553+
print(" " + "-" * 45)
554+
555+
m2m = ManyToManyRelationshipMetadata(
556+
schema_name="test_relchild_relproject",
557+
entity1_logical_name=child_info["table_logical_name"],
558+
entity2_logical_name=proj_info["table_logical_name"],
559+
)
560+
561+
result_nn = backoff(lambda: client.tables.create_many_to_many_relationship(relationship=m2m))
562+
563+
assert result_nn.relationship_schema_name == "test_relchild_relproject"
564+
assert result_nn.relationship_type == "many_to_many"
565+
rel_id_nn = result_nn.relationship_id
566+
print(f" [OK] Created N:N relationship: {result_nn.relationship_schema_name}")
567+
print(f" ID: {rel_id_nn}")
568+
569+
# --- Test 4: Get relationship metadata ---
570+
print("\n Test 4: Query relationship metadata")
571+
print(" " + "-" * 45)
572+
573+
fetched_1n = client.tables.get_relationship("test_RelParent_RelChild")
574+
assert fetched_1n is not None
575+
assert fetched_1n.relationship_type == "one_to_many"
576+
assert fetched_1n.relationship_id == rel_id_1n
577+
print(f" [OK] Retrieved 1:N: {fetched_1n.relationship_schema_name}")
578+
print(f" Referenced: {fetched_1n.referenced_entity}")
579+
print(f" Referencing: {fetched_1n.referencing_entity}")
580+
581+
fetched_nn = client.tables.get_relationship("test_relchild_relproject")
582+
assert fetched_nn is not None
583+
assert fetched_nn.relationship_type == "many_to_many"
584+
assert fetched_nn.relationship_id == rel_id_nn
585+
print(f" [OK] Retrieved N:N: {fetched_nn.relationship_schema_name}")
586+
print(f" Entity1: {fetched_nn.entity1_logical_name}")
587+
print(f" Entity2: {fetched_nn.entity2_logical_name}")
588+
589+
# Non-existent relationship should return None
590+
missing = client.tables.get_relationship("nonexistent_relationship_xyz")
591+
assert missing is None
592+
print(" [OK] Non-existent relationship returns None")
593+
594+
# --- Test 5: Delete relationships ---
595+
print("\n Test 5: Delete relationships")
596+
print(" " + "-" * 45)
597+
598+
backoff(lambda: client.tables.delete_relationship(rel_id_1n))
599+
rel_id_1n = None
600+
print(" [OK] Deleted 1:N relationship")
601+
602+
backoff(lambda: client.tables.delete_relationship(rel_id_lookup))
603+
rel_id_lookup = None
604+
print(" [OK] Deleted lookup relationship")
605+
606+
backoff(lambda: client.tables.delete_relationship(rel_id_nn))
607+
rel_id_nn = None
608+
print(" [OK] Deleted N:N relationship")
609+
610+
# Verify deletion
611+
verify = client.tables.get_relationship("test_RelParent_RelChild")
612+
assert verify is None
613+
print(" [OK] Verified 1:N deletion (get returns None)")
614+
615+
print("\n[OK] All relationship tests passed!")
616+
617+
finally:
618+
# Cleanup: delete any remaining relationships then tables
619+
for rid in [rel_id_1n, rel_id_lookup, rel_id_nn]:
620+
if rid:
621+
try:
622+
client.tables.delete_relationship(rid)
623+
except Exception:
624+
pass
625+
626+
for tbl in reversed(created_tables):
627+
try:
628+
backoff(lambda name=tbl: client.tables.delete(name))
629+
print(f" (Cleaned up table: {tbl})")
630+
except Exception as e:
631+
print(f" [WARN] Could not delete {tbl}: {e}")
632+
633+
383634
def _table_still_exists(client: DataverseClient, table_schema_name: Optional[str]) -> bool:
384635
if not table_schema_name:
385636
return False
@@ -403,6 +654,7 @@ def main():
403654
print(" - Table Creation & Metadata Operations")
404655
print(" - Record CRUD Operations")
405656
print(" - Query Functionality")
657+
print(" - Relationship Operations (1:N, N:N, lookup, get, delete)")
406658
print(" - Interactive Cleanup")
407659
print("=" * 70)
408660
print("For installation validation, run examples/basic/installation_example.py first")
@@ -422,6 +674,9 @@ def main():
422674
# Test querying
423675
test_query_records(client, table_info)
424676

677+
# Test relationships
678+
test_relationships(client)
679+
425680
# Success summary
426681
print("\nFunctional Test Summary")
427682
print("=" * 50)
@@ -430,6 +685,7 @@ def main():
430685
print("[OK] Record Creation: Success")
431686
print("[OK] Record Reading: Success")
432687
print("[OK] Record Querying: Success")
688+
print("[OK] Relationship Operations: Success")
433689
print("\nYour PowerPlatform Dataverse Client SDK is fully functional!")
434690

435691
# Cleanup

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,8 @@ select = [
9090
"UP", # pyupgrade
9191
"B", # flake8-bugbear
9292
]
93+
94+
[tool.pytest.ini_options]
95+
testpaths = ["tests/unit"]
96+
# e2e tests require a live Dataverse environment:
97+
# 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)