From f57826ce16045ef41ad7f204f2279f89682bc3c4 Mon Sep 17 00:00:00 2001 From: Joeloo1 Date: Sun, 31 May 2026 18:01:51 +0100 Subject: [PATCH 1/4] feat(course-registry): add course ownership transfer Implements transfer_ownership() allowing the current instructor to transfer a course to a new address. Enforces require_auth() and instructor identity checks, updates the stored Course struct, and emits an OwnershipTransferred event. Adds 6 tests covering the happy path, auth rejection, nonexistent course, event emission, and post-transfer metadata update by the new owner. Closes #58 --- contracts/course-registry/src/lib.rs | 43 +++ contracts/course-registry/src/test.rs | 84 +++++ ...test_transfer_ownership_emits_event.1.json | 335 +++++++++++++++++ ..._new_instructor_can_update_metadata.1.json | 355 ++++++++++++++++++ ...fer_ownership_non_instructor_panics.1.json | 242 ++++++++++++ ...ownership_nonexistent_course_panics.1.json | 91 +++++ .../test_transfer_ownership_success.1.json | 300 +++++++++++++++ ..._ownership_updates_instructor_field.1.json | 301 +++++++++++++++ 8 files changed, 1751 insertions(+) create mode 100644 contracts/course-registry/test_snapshots/test/test_transfer_ownership_emits_event.1.json create mode 100644 contracts/course-registry/test_snapshots/test/test_transfer_ownership_new_instructor_can_update_metadata.1.json create mode 100644 contracts/course-registry/test_snapshots/test/test_transfer_ownership_non_instructor_panics.1.json create mode 100644 contracts/course-registry/test_snapshots/test/test_transfer_ownership_nonexistent_course_panics.1.json create mode 100644 contracts/course-registry/test_snapshots/test/test_transfer_ownership_success.1.json create mode 100644 contracts/course-registry/test_snapshots/test/test_transfer_ownership_updates_instructor_field.1.json diff --git a/contracts/course-registry/src/lib.rs b/contracts/course-registry/src/lib.rs index 8eea5ec..8c4218d 100644 --- a/contracts/course-registry/src/lib.rs +++ b/contracts/course-registry/src/lib.rs @@ -32,6 +32,15 @@ pub struct CourseStatusChanged { pub active: bool, } +#[contractevent] +pub struct OwnershipTransferred { + #[topic] + pub course_id: u32, + #[topic] + pub previous_instructor: Address, + pub new_instructor: Address, +} + #[contractevent] pub struct ModuleCompleted { #[topic] @@ -234,6 +243,40 @@ impl CourseRegistry { env.storage().persistent().get(&key).unwrap_or(0) } + /// Transfers ownership of a course to a new instructor address. + /// Only callable by the current instructor of the course. + pub fn transfer_ownership( + env: Env, + current_instructor: Address, + new_instructor: Address, + course_id: u32, + ) { + let mut course: Course = env + .storage() + .persistent() + .get(&DataKey::Course(course_id)) + .expect("Course not found"); + + assert!( + course.instructor == current_instructor, + "Unauthorized: Caller is not the course instructor" + ); + + current_instructor.require_auth(); + + course.instructor = new_instructor.clone(); + env.storage() + .persistent() + .set(&DataKey::Course(course_id), &course); + + OwnershipTransferred { + course_id, + previous_instructor: current_instructor, + new_instructor, + } + .publish(&env); + } + /// Records a learner's completion of a module after off-chain quiz validation. /// Only callable by the authorized verifier (protocol admin). pub fn complete_module(env: Env, verifier: Address, learner: Address, id: u32) { diff --git a/contracts/course-registry/src/test.rs b/contracts/course-registry/src/test.rs index 560773f..625caeb 100644 --- a/contracts/course-registry/src/test.rs +++ b/contracts/course-registry/src/test.rs @@ -563,3 +563,87 @@ fn test_get_progress_tracks_completion() { client.complete_module(&admin, &learner, &course_id); assert_eq!(client.get_progress(&learner, &course_id), 3); } + +// ── transfer_ownership ──────────────────────────────────────────────────────── + +#[test] +fn test_transfer_ownership_success() { + let (env, client) = setup(); + let (_, instructor, id) = setup_with_course(&env, &client); + let new_instructor = Address::generate(&env); + + client.transfer_ownership(&instructor, &new_instructor, &id); + + let course = client.get_course(&id); + assert_eq!(course.instructor, new_instructor); +} + +#[test] +fn test_transfer_ownership_emits_event() { + let (env, client) = setup(); + let (_, instructor, id) = setup_with_course(&env, &client); + let new_instructor = Address::generate(&env); + + client.transfer_ownership(&instructor, &new_instructor, &id); + + // One OwnershipTransferred event must have been emitted + assert_eq!(env.events().all().len(), 1); +} + +#[test] +#[should_panic(expected = "Unauthorized: Caller is not the course instructor")] +fn test_transfer_ownership_non_instructor_panics() { + let (env, client) = setup(); + let (_, _, id) = setup_with_course(&env, &client); + let impostor = Address::generate(&env); + let new_instructor = Address::generate(&env); + + client.transfer_ownership(&impostor, &new_instructor, &id); +} + +#[test] +#[should_panic(expected = "Course not found")] +fn test_transfer_ownership_nonexistent_course_panics() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let instructor = Address::generate(&env); + let new_instructor = Address::generate(&env); + + client.initialize(&admin); + + client.transfer_ownership(&instructor, &new_instructor, &99); +} + +#[test] +fn test_transfer_ownership_new_instructor_can_update_metadata() { + let (env, client) = setup(); + let (_, instructor, id) = setup_with_course(&env, &client); + let new_instructor = Address::generate(&env); + + client.transfer_ownership(&instructor, &new_instructor, &id); + + // New instructor must be able to update metadata after ownership transfer + let updated_hash = BytesN::from_array(&env, &[9u8; 32]); + client.update_metadata(&id, &updated_hash); + + let course = client.get_course(&id); + assert_eq!(course.metadata_hash, updated_hash); +} + +#[test] +fn test_transfer_ownership_updates_instructor_field() { + let (env, client) = setup(); + let (_, instructor, id) = setup_with_course(&env, &client); + let new_instructor = Address::generate(&env); + + // Confirm original instructor before transfer + let before = client.get_course(&id); + assert_eq!(before.instructor, instructor); + + client.transfer_ownership(&instructor, &new_instructor, &id); + + // Confirm instructor field reflects the new address after transfer + let after = client.get_course(&id); + assert_eq!(after.instructor, new_instructor); + assert_ne!(after.instructor, instructor); +} diff --git a/contracts/course-registry/test_snapshots/test/test_transfer_ownership_emits_event.1.json b/contracts/course-registry/test_snapshots/test/test_transfer_ownership_emits_event.1.json new file mode 100644 index 0000000..c3ea3dd --- /dev/null +++ b/contracts/course-registry/test_snapshots/test/test_transfer_ownership_emits_event.1.json @@ -0,0 +1,335 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "create_course", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "u32": 5 + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "transfer_ownership", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "u32": 1 + } + ] + } + }, + "sub_invocations": [] + } + ] + ] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Course" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Course" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "active" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "instructor" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "metadata_hash" + }, + "val": { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + }, + { + "key": { + "symbol": "total_modules" + }, + "val": { + "u32": 5 + } + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "CourseCount" + } + ] + }, + "val": { + "u32": 1 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [ + { + "event": { + "ext": "v0", + "contract_id": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "type_": "contract", + "body": { + "v0": { + "topics": [ + { + "symbol": "ownership_transferred" + }, + { + "u32": 1 + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + ], + "data": { + "map": [ + { + "key": { + "symbol": "new_instructor" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + } + ] + } + } + } + }, + "failed_call": false + } + ] +} \ No newline at end of file diff --git a/contracts/course-registry/test_snapshots/test/test_transfer_ownership_new_instructor_can_update_metadata.1.json b/contracts/course-registry/test_snapshots/test/test_transfer_ownership_new_instructor_can_update_metadata.1.json new file mode 100644 index 0000000..b257d5e --- /dev/null +++ b/contracts/course-registry/test_snapshots/test/test_transfer_ownership_new_instructor_can_update_metadata.1.json @@ -0,0 +1,355 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "create_course", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "u32": 5 + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "transfer_ownership", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "u32": 1 + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "update_metadata", + "args": [ + { + "u32": 1 + }, + { + "bytes": "0909090909090909090909090909090909090909090909090909090909090909" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Course" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Course" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "active" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "instructor" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "metadata_hash" + }, + "val": { + "bytes": "0909090909090909090909090909090909090909090909090909090909090909" + } + }, + { + "key": { + "symbol": "total_modules" + }, + "val": { + "u32": 5 + } + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "CourseCount" + } + ] + }, + "val": { + "u32": 1 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/course-registry/test_snapshots/test/test_transfer_ownership_non_instructor_panics.1.json b/contracts/course-registry/test_snapshots/test/test_transfer_ownership_non_instructor_panics.1.json new file mode 100644 index 0000000..1963ef2 --- /dev/null +++ b/contracts/course-registry/test_snapshots/test/test_transfer_ownership_non_instructor_panics.1.json @@ -0,0 +1,242 @@ +{ + "generators": { + "address": 5, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "create_course", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "u32": 5 + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Course" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Course" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "active" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "instructor" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + } + }, + { + "key": { + "symbol": "metadata_hash" + }, + "val": { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + }, + { + "key": { + "symbol": "total_modules" + }, + "val": { + "u32": 5 + } + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "CourseCount" + } + ] + }, + "val": { + "u32": 1 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/course-registry/test_snapshots/test/test_transfer_ownership_nonexistent_course_panics.1.json b/contracts/course-registry/test_snapshots/test/test_transfer_ownership_nonexistent_course_panics.1.json new file mode 100644 index 0000000..c542bbb --- /dev/null +++ b/contracts/course-registry/test_snapshots/test/test_transfer_ownership_nonexistent_course_panics.1.json @@ -0,0 +1,91 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/course-registry/test_snapshots/test/test_transfer_ownership_success.1.json b/contracts/course-registry/test_snapshots/test/test_transfer_ownership_success.1.json new file mode 100644 index 0000000..b2493d2 --- /dev/null +++ b/contracts/course-registry/test_snapshots/test/test_transfer_ownership_success.1.json @@ -0,0 +1,300 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "create_course", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "u32": 5 + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "transfer_ownership", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "u32": 1 + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Course" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Course" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "active" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "instructor" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "metadata_hash" + }, + "val": { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + }, + { + "key": { + "symbol": "total_modules" + }, + "val": { + "u32": 5 + } + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "CourseCount" + } + ] + }, + "val": { + "u32": 1 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file diff --git a/contracts/course-registry/test_snapshots/test/test_transfer_ownership_updates_instructor_field.1.json b/contracts/course-registry/test_snapshots/test/test_transfer_ownership_updates_instructor_field.1.json new file mode 100644 index 0000000..2599c60 --- /dev/null +++ b/contracts/course-registry/test_snapshots/test/test_transfer_ownership_updates_instructor_field.1.json @@ -0,0 +1,301 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "create_course", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "u32": 5 + }, + { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "function_name": "transfer_ownership", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "u32": 1 + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 23, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Course" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "vec": [ + { + "symbol": "Course" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "active" + }, + "val": { + "bool": true + } + }, + { + "key": { + "symbol": "instructor" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "metadata_hash" + }, + "val": { + "bytes": "0101010101010101010101010101010101010101010101010101010101010101" + } + }, + { + "key": { + "symbol": "total_modules" + }, + "val": { + "u32": 5 + } + } + ] + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Admin" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + }, + { + "key": { + "vec": [ + { + "symbol": "CourseCount" + } + ] + }, + "val": { + "u32": 1 + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + 6311999 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} \ No newline at end of file From a37d9eac44263a4a79b1b0fc403be891465dd85b Mon Sep 17 00:00:00 2001 From: Joeloo1 Date: Tue, 2 Jun 2026 14:14:20 +0100 Subject: [PATCH 2/4] fix(course-registry): restore badge tests and append transfer_ownership tests Restores the badge NFT test section that was accidentally replaced instead of appended, fixing the unclosed delimiter CI parse error. --- contracts/course-registry/src/test.rs | 126 ++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/contracts/course-registry/src/test.rs b/contracts/course-registry/src/test.rs index 95d1ed5..a0029f1 100644 --- a/contracts/course-registry/src/test.rs +++ b/contracts/course-registry/src/test.rs @@ -565,6 +565,132 @@ fn test_get_progress_tracks_completion() { assert_eq!(client.get_progress(&learner, &course_id), 3); } +// ── Badge minting on course completion ─────────────────────────────────────── + +/// Helper: deploys and initializes a BadgeNFT contract, authorizing the given registry address. +fn setup_badge_nft<'a>(env: &Env, registry_address: &Address) -> BadgeNFTClient<'a> { + let badge_id = env.register(BadgeNFT, ()); + let badge_client = BadgeNFTClient::new(env, &badge_id); + badge_client.initialize(registry_address); + badge_client +} + +#[test] +fn test_badge_minted_on_final_module_completion() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let instructor = Address::generate(&env); + let learner = Address::generate(&env); + + client.initialize(&admin); + let course_id = client.create_course(&admin, &instructor, &2, &dummy_hash(&env)); + + // Deploy BadgeNFT and wire it up + let badge_client = setup_badge_nft(&env, &client.address); + client.set_badge_nft_address(&admin, &badge_client.address); + + // Complete module 1 — no badge yet + client.complete_module(&admin, &learner, &course_id); + assert!(!badge_client.has_badge(&learner, &course_id)); + + // Complete module 2 (final) — badge must be minted + client.complete_module(&admin, &learner, &course_id); + assert!(badge_client.has_badge(&learner, &course_id)); +} + +#[test] +fn test_badge_not_minted_on_intermediate_module() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let instructor = Address::generate(&env); + let learner = Address::generate(&env); + + client.initialize(&admin); + let course_id = client.create_course(&admin, &instructor, &3, &dummy_hash(&env)); + + let badge_client = setup_badge_nft(&env, &client.address); + client.set_badge_nft_address(&admin, &badge_client.address); + + // Complete only the first two of three modules + client.complete_module(&admin, &learner, &course_id); + client.complete_module(&admin, &learner, &course_id); + + assert!(!badge_client.has_badge(&learner, &course_id)); +} + +#[test] +fn test_badge_minted_for_multiple_learners_independently() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let instructor = Address::generate(&env); + let learner_a = Address::generate(&env); + let learner_b = Address::generate(&env); + + client.initialize(&admin); + let course_id = client.create_course(&admin, &instructor, &1, &dummy_hash(&env)); + + let badge_client = setup_badge_nft(&env, &client.address); + client.set_badge_nft_address(&admin, &badge_client.address); + + // Each learner completes the single-module course + client.complete_module(&admin, &learner_a, &course_id); + client.complete_module(&admin, &learner_b, &course_id); + + assert!(badge_client.has_badge(&learner_a, &course_id)); + assert!(badge_client.has_badge(&learner_b, &course_id)); + assert_eq!(badge_client.get_badge_count(&learner_a), 1); + assert_eq!(badge_client.get_badge_count(&learner_b), 1); +} + +#[test] +fn test_badge_minted_for_different_courses_same_learner() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let instructor = Address::generate(&env); + let learner = Address::generate(&env); + + client.initialize(&admin); + let course_a = client.create_course(&admin, &instructor, &1, &dummy_hash(&env)); + let course_b = client.create_course(&admin, &instructor, &1, &dummy_hash(&env)); + + let badge_client = setup_badge_nft(&env, &client.address); + client.set_badge_nft_address(&admin, &badge_client.address); + + client.complete_module(&admin, &learner, &course_a); + client.complete_module(&admin, &learner, &course_b); + + assert!(badge_client.has_badge(&learner, &course_a)); + assert!(badge_client.has_badge(&learner, &course_b)); + assert_eq!(badge_client.get_badge_count(&learner), 2); +} + +#[test] +fn test_complete_module_without_badge_nft_configured_does_not_panic() { + // If set_badge_nft_address was never called, completion should still succeed + let (env, client) = setup(); + let admin = Address::generate(&env); + let instructor = Address::generate(&env); + let learner = Address::generate(&env); + + client.initialize(&admin); + let course_id = client.create_course(&admin, &instructor, &1, &dummy_hash(&env)); + + // No badge NFT address set — final module completion must not panic + client.complete_module(&admin, &learner, &course_id); +} + +#[test] +#[should_panic(expected = "Unauthorized: Caller is not the protocol admin")] +fn test_set_badge_nft_address_unauthorized_panics() { + let (env, client) = setup(); + let admin = Address::generate(&env); + let fake_admin = Address::generate(&env); + let badge_address = Address::generate(&env); + + client.initialize(&admin); + client.set_badge_nft_address(&fake_admin, &badge_address); +} + // ── transfer_ownership ──────────────────────────────────────────────────────── #[test] From 3d53c0a21ac8207ff096143907a0e6469bb813db Mon Sep 17 00:00:00 2001 From: Joeloo1 Date: Wed, 3 Jun 2026 14:53:44 +0100 Subject: [PATCH 3/4] fix(course-registry): add missing closing brace in transfer ownership test Co-Authored-By: Claude Sonnet 4.6 --- contracts/course-registry/src/test.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/course-registry/src/test.rs b/contracts/course-registry/src/test.rs index a0029f1..d228a82 100644 --- a/contracts/course-registry/src/test.rs +++ b/contracts/course-registry/src/test.rs @@ -773,6 +773,8 @@ fn test_transfer_ownership_updates_instructor_field() { let after = client.get_course(&id); assert_eq!(after.instructor, new_instructor); assert_ne!(after.instructor, instructor); +} + // ── Badge minting on course completion ─────────────────────────────────────── /// Helper: deploys and initializes a BadgeNFT contract, authorizing the given registry address. From 2bc5cada2f56e5d10ce7632c1171d100e049417c Mon Sep 17 00:00:00 2001 From: Joeloo1 Date: Wed, 3 Jun 2026 15:03:17 +0100 Subject: [PATCH 4/4] fix(course-registry): remove duplicate badge test functions Co-Authored-By: Claude Sonnet 4.6 --- contracts/course-registry/src/test.rs | 126 -------------------------- 1 file changed, 126 deletions(-) diff --git a/contracts/course-registry/src/test.rs b/contracts/course-registry/src/test.rs index d228a82..5f3513c 100644 --- a/contracts/course-registry/src/test.rs +++ b/contracts/course-registry/src/test.rs @@ -774,129 +774,3 @@ fn test_transfer_ownership_updates_instructor_field() { assert_eq!(after.instructor, new_instructor); assert_ne!(after.instructor, instructor); } - -// ── Badge minting on course completion ─────────────────────────────────────── - -/// Helper: deploys and initializes a BadgeNFT contract, authorizing the given registry address. -fn setup_badge_nft<'a>(env: &Env, registry_address: &Address) -> BadgeNFTClient<'a> { - let badge_id = env.register(BadgeNFT, ()); - let badge_client = BadgeNFTClient::new(env, &badge_id); - badge_client.initialize(registry_address); - badge_client -} - -#[test] -fn test_badge_minted_on_final_module_completion() { - let (env, client) = setup(); - let admin = Address::generate(&env); - let instructor = Address::generate(&env); - let learner = Address::generate(&env); - - client.initialize(&admin); - let course_id = client.create_course(&admin, &instructor, &2, &dummy_hash(&env)); - - // Deploy BadgeNFT and wire it up - let badge_client = setup_badge_nft(&env, &client.address); - client.set_badge_nft_address(&admin, &badge_client.address); - - // Complete module 1 — no badge yet - client.complete_module(&admin, &learner, &course_id); - assert!(!badge_client.has_badge(&learner, &course_id)); - - // Complete module 2 (final) — badge must be minted - client.complete_module(&admin, &learner, &course_id); - assert!(badge_client.has_badge(&learner, &course_id)); -} - -#[test] -fn test_badge_not_minted_on_intermediate_module() { - let (env, client) = setup(); - let admin = Address::generate(&env); - let instructor = Address::generate(&env); - let learner = Address::generate(&env); - - client.initialize(&admin); - let course_id = client.create_course(&admin, &instructor, &3, &dummy_hash(&env)); - - let badge_client = setup_badge_nft(&env, &client.address); - client.set_badge_nft_address(&admin, &badge_client.address); - - // Complete only the first two of three modules - client.complete_module(&admin, &learner, &course_id); - client.complete_module(&admin, &learner, &course_id); - - assert!(!badge_client.has_badge(&learner, &course_id)); -} - -#[test] -fn test_badge_minted_for_multiple_learners_independently() { - let (env, client) = setup(); - let admin = Address::generate(&env); - let instructor = Address::generate(&env); - let learner_a = Address::generate(&env); - let learner_b = Address::generate(&env); - - client.initialize(&admin); - let course_id = client.create_course(&admin, &instructor, &1, &dummy_hash(&env)); - - let badge_client = setup_badge_nft(&env, &client.address); - client.set_badge_nft_address(&admin, &badge_client.address); - - // Each learner completes the single-module course - client.complete_module(&admin, &learner_a, &course_id); - client.complete_module(&admin, &learner_b, &course_id); - - assert!(badge_client.has_badge(&learner_a, &course_id)); - assert!(badge_client.has_badge(&learner_b, &course_id)); - assert_eq!(badge_client.get_badge_count(&learner_a), 1); - assert_eq!(badge_client.get_badge_count(&learner_b), 1); -} - -#[test] -fn test_badge_minted_for_different_courses_same_learner() { - let (env, client) = setup(); - let admin = Address::generate(&env); - let instructor = Address::generate(&env); - let learner = Address::generate(&env); - - client.initialize(&admin); - let course_a = client.create_course(&admin, &instructor, &1, &dummy_hash(&env)); - let course_b = client.create_course(&admin, &instructor, &1, &dummy_hash(&env)); - - let badge_client = setup_badge_nft(&env, &client.address); - client.set_badge_nft_address(&admin, &badge_client.address); - - client.complete_module(&admin, &learner, &course_a); - client.complete_module(&admin, &learner, &course_b); - - assert!(badge_client.has_badge(&learner, &course_a)); - assert!(badge_client.has_badge(&learner, &course_b)); - assert_eq!(badge_client.get_badge_count(&learner), 2); -} - -#[test] -fn test_complete_module_without_badge_nft_configured_does_not_panic() { - // If set_badge_nft_address was never called, completion should still succeed - let (env, client) = setup(); - let admin = Address::generate(&env); - let instructor = Address::generate(&env); - let learner = Address::generate(&env); - - client.initialize(&admin); - let course_id = client.create_course(&admin, &instructor, &1, &dummy_hash(&env)); - - // No badge NFT address set — final module completion must not panic - client.complete_module(&admin, &learner, &course_id); -} - -#[test] -#[should_panic(expected = "Unauthorized: Caller is not the protocol admin")] -fn test_set_badge_nft_address_unauthorized_panics() { - let (env, client) = setup(); - let admin = Address::generate(&env); - let fake_admin = Address::generate(&env); - let badge_address = Address::generate(&env); - - client.initialize(&admin); - client.set_badge_nft_address(&fake_admin, &badge_address); -}