diff --git a/data-machine-events.php b/data-machine-events.php index fead1df..13d0f6b 100644 --- a/data-machine-events.php +++ b/data-machine-events.php @@ -120,6 +120,8 @@ function datamachine_get_event_timing( int $post_id ): string { \WP_CLI::add_command( 'data-machine-events check clean-duplicates', \DataMachineEvents\Cli\Check\CleanDuplicatesCommand::class ); \WP_CLI::add_command( 'data-machine-events check merged-bills', \DataMachineEvents\Cli\Check\CheckMergedBillsCommand::class ); \WP_CLI::add_command( 'data-machine-events check merge-duplicate-venues', \DataMachineEvents\Cli\Check\CheckMergeDuplicateVenuesCommand::class ); + \WP_CLI::add_command( 'data-machine-events check missing-venue-addresses', \DataMachineEvents\Cli\Check\CheckMissingVenueAddressesCommand::class ); + \WP_CLI::add_command( 'data-machine-events check orphan-venues', \DataMachineEvents\Cli\Check\CheckOrphanVenuesCommand::class ); \WP_CLI::add_command( 'data-machine-events check quality', \DataMachineEvents\Cli\Check\CheckQualityCommand::class ); \WP_CLI::add_command( 'data-machine-events check all', \DataMachineEvents\Cli\Check\CheckAllCommand::class ); } @@ -410,6 +412,11 @@ function ( array $templates ): array { new \DataMachineEvents\Abilities\GeocodingAbilities(); } + if ( file_exists( DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/VenueStatsAbilities.php' ) ) { + require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/VenueStatsAbilities.php'; + new \DataMachineEvents\Abilities\VenueStatsAbilities(); + } + if ( file_exists( DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/FilterAbilities.php' ) ) { require_once DATA_MACHINE_EVENTS_PLUGIN_DIR . 'inc/Abilities/FilterAbilities.php'; new \DataMachineEvents\Abilities\FilterAbilities(); diff --git a/inc/Abilities/VenueStatsAbilities.php b/inc/Abilities/VenueStatsAbilities.php new file mode 100644 index 0000000..f732ab5 --- /dev/null +++ b/inc/Abilities/VenueStatsAbilities.php @@ -0,0 +1,137 @@ +registerAbilities(); + self::$registered = true; + } + } + + private function registerAbilities(): void { + $register_callback = function () { + wp_register_ability( + 'data-machine-events/venue-stats', + array( + 'label' => __( 'Venue Stats', 'data-machine-events' ), + 'description' => __( 'Network-cheap counts for the venue audit digest: total venues, terms with no _venue_address, and orphan terms (wp_term_taxonomy.count = 0).', 'data-machine-events' ), + 'category' => 'datamachine-events-venues', + 'input_schema' => array( + 'type' => 'object', + 'properties' => new \stdClass(), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'no_address' => array( + 'type' => 'integer', + 'description' => 'Count of venue terms whose _venue_address meta is empty or missing.', + ), + 'orphans' => array( + 'type' => 'integer', + 'description' => 'Count of venue terms whose wp_term_taxonomy.count = 0.', + ), + 'total' => array( + 'type' => 'integer', + 'description' => 'Total count of venue terms.', + ), + 'queried_at' => array( + 'type' => 'integer', + 'description' => 'Unix timestamp at which the stats were computed.', + ), + ), + ), + 'execute_callback' => array( $this, 'executeVenueStats' ), + 'permission_callback' => function () { + return current_user_can( 'manage_options' ) + || ( defined( 'WP_CLI' ) && WP_CLI ); + }, + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'readonly' => true, + 'idempotent' => true, + ), + ), + ) + ); + }; + + if ( did_action( 'wp_abilities_api_init' ) ) { + $register_callback(); + } else { + add_action( 'wp_abilities_api_init', $register_callback ); + } + } + + /** + * Execute the venue-stats ability. + * + * Uses two cheap aggregate queries instead of pulling every term + * into PHP. The digest is expected to call this weekly across + * multiple sites, so we keep the cost bounded. + * + * @param array $input Unused — the ability takes no inputs. + * @return array{no_address:int,orphans:int,total:int,queried_at:int} + */ + public function executeVenueStats( array $input ): array { + global $wpdb; + + $total = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$wpdb->term_taxonomy} WHERE taxonomy = 'venue'" + ); + + $orphans = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$wpdb->term_taxonomy} + WHERE taxonomy = 'venue' AND count = 0" + ); + + // no_address: venue terms whose `_venue_address` meta is NULL, + // missing, or an empty string. Left-join so terms with no row + // in termmeta at all are still counted. + $no_address = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$wpdb->term_taxonomy} tt + LEFT JOIN {$wpdb->termmeta} tm + ON tm.term_id = tt.term_id + AND tm.meta_key = '_venue_address' + WHERE tt.taxonomy = 'venue' + AND ( tm.meta_value IS NULL OR tm.meta_value = '' )" + ); + + return array( + 'no_address' => $no_address, + 'orphans' => $orphans, + 'total' => $total, + 'queried_at' => time(), + ); + } +} diff --git a/inc/Cli/Check/CheckMissingVenueAddressesCommand.php b/inc/Cli/Check/CheckMissingVenueAddressesCommand.php new file mode 100644 index 0000000..1bdb521 --- /dev/null +++ b/inc/Cli/Check/CheckMissingVenueAddressesCommand.php @@ -0,0 +1,532 @@ + + */ + private const ADDRESS_META_KEYS = array( + 'address' => '_venue_address', + 'city' => '_venue_city', + 'state' => '_venue_state', + 'zip' => '_venue_zip', + 'country' => '_venue_country', + ); + + /** + * Audit + repair venue terms with empty `_venue_address` meta. + * + * ## OPTIONS + * + * [--dry-run] + * : Show what would be repaired without writing. Default behavior — + * pass --apply to commit changes. + * + * [--apply] + * : Actually perform the repairs. Without this flag the command + * behaves as --dry-run. + * + * [--limit=] + * : Cap the number of venue terms processed per run. + * --- + * default: 50 + * --- + * + * [--format=] + * : Output format for the per-venue table. + * --- + * default: table + * options: + * - table + * - csv + * - json + * --- + * + * ## EXAMPLES + * + * wp data-machine-events check missing-venue-addresses --dry-run + * wp data-machine-events check missing-venue-addresses --apply --limit=10 + * wp data-machine-events check missing-venue-addresses --dry-run --format=csv + * + * @param array $args Positional arguments. + * @param array $assoc_args Named arguments. + */ + public function __invoke( array $args, array $assoc_args ): void { + $apply = isset( $assoc_args['apply'] ); + $limit = max( 1, (int) ( $assoc_args['limit'] ?? 50 ) ); + $format = (string) ( $assoc_args['format'] ?? 'table' ); + + // Default to dry-run unless --apply is passed. + $dry_run = ! $apply; + + $candidates = $this->find_candidates(); + + if ( empty( $candidates ) ) { + \WP_CLI::success( 'No venue terms with missing _venue_address detected.' ); + return; + } + + \WP_CLI::log( sprintf( 'Detected %d venue term(s) with missing address meta.', count( $candidates ) ) ); + + if ( count( $candidates ) > $limit ) { + \WP_CLI::log( sprintf( 'Processing first %d this run (use --limit=N to change).', $limit ) ); + $candidates = array_slice( $candidates, 0, $limit ); + } + + $rows = array(); + + foreach ( $candidates as $term ) { + $rows[] = $this->process_candidate( $term, $dry_run ); + } + + \WP_CLI\Utils\format_items( + $format, + $rows, + array( + 'term_id', + 'term_name', + 'action_taken', + 'fields_filled', + ) + ); + + if ( $dry_run ) { + \WP_CLI::log( '' ); + \WP_CLI::log( 'DRY RUN — no changes made. Re-run with --apply to commit.' ); + return; + } + + $filled = 0; + $no_repair = 0; + foreach ( $rows as $row ) { + if ( ! empty( $row['fields_filled'] ) ) { + ++$filled; + } + if ( 'no_repair_possible' === $row['action_taken'] ) { + ++$no_repair; + } + } + + \WP_CLI::success( + sprintf( + 'Processed %d venue(s). Filled at least one field on %d. %d had no repair path.', + count( $rows ), + $filled, + $no_repair + ) + ); + } + + /** + * Return every venue term whose `_venue_address` meta is empty or + * missing. Mirrors the audit query at issue #277. + * + * @return \WP_Term[] + */ + private function find_candidates(): array { + $terms = get_terms( + array( + 'taxonomy' => 'venue', + 'hide_empty' => false, + 'number' => 0, + ) + ); + + if ( is_wp_error( $terms ) || empty( $terms ) ) { + return array(); + } + + $candidates = array(); + + foreach ( $terms as $term ) { + $address = get_term_meta( $term->term_id, '_venue_address', true ); + if ( '' === trim( (string) $address ) ) { + $candidates[] = $term; + } + } + + return $candidates; + } + + /** + * Process one venue term: try reverse-geocode → places-lookup → + * residue. Returns one row for the output table. + * + * @param \WP_Term $term Venue term. + * @param bool $dry_run Skip writes. + * @return array + */ + private function process_candidate( \WP_Term $term, bool $dry_run ): array { + $row = array( + 'term_id' => (int) $term->term_id, + 'term_name' => (string) $term->name, + 'action_taken' => 'no_repair_possible', + 'fields_filled' => '', + ); + + $coords = (string) get_term_meta( $term->term_id, '_venue_coordinates', true ); + $city = (string) get_term_meta( $term->term_id, '_venue_city', true ); + + // Step 1: reverse geocode from coordinates. + if ( '' !== trim( $coords ) ) { + $parsed = $this->reverse_geocode( $coords ); + + if ( null !== $parsed && '' !== trim( (string) ( $parsed['address'] ?? '' ) ) ) { + $filled = $this->apply_smart_merge( $term->term_id, $parsed, $dry_run ); + if ( ! empty( $filled ) ) { + $row['action_taken'] = 'geocoded'; + $row['fields_filled'] = implode( ',', $filled ); + return $row; + } + + // Reverse-geocoded but everything was already populated + // (only `_venue_address` was missing and the lookup + // returned nothing parseable for it). Surface that so + // the operator does not assume the lookup failed. + $row['action_taken'] = 'smart_merge_skipped_existing'; + return $row; + } + } + + // Step 2: places-lookup fallback (no coords, but we have a city). + if ( '' !== trim( $city ) ) { + $candidate = $this->places_lookup( (string) $term->name, $city ); + + if ( null !== $candidate && '' !== trim( (string) ( $candidate['address'] ?? '' ) ) ) { + // Name-similarity gate: reject the lookup unless the + // candidate's display name overlaps the term name well + // enough. Mirrors the VenueMergeHelper guard so we do + // not silently rewrite "The Local Bar" → "Texas Music + // Theater" just because Nominatim returned a top hit. + $candidate_name = (string) ( $candidate['display_name_short'] ?? '' ); + + if ( '' === $candidate_name + || ! VenueMergeHelper::names_are_similar( (string) $term->name, $candidate_name ) + ) { + $row['action_taken'] = 'no_repair_possible'; + return $row; + } + + $filled = $this->apply_smart_merge( $term->term_id, $candidate, $dry_run ); + if ( ! empty( $filled ) ) { + $row['action_taken'] = 'places_lookup'; + $row['fields_filled'] = implode( ',', $filled ); + return $row; + } + + $row['action_taken'] = 'smart_merge_skipped_existing'; + return $row; + } + } + + // Step 3: residue. Neither coords nor city — operator review. + return $row; + } + + /** + * Smart-merge a parsed address payload into a venue term. Only fills + * empty meta fields; never overwrites a curated value. + * + * @param int $term_id Venue term ID. + * @param array $components Parsed address components keyed by + * 'address' / 'city' / 'state' / 'zip' / 'country'. + * @param bool $dry_run Skip writes. + * @return array Meta keys that were (or would be) filled. + */ + private function apply_smart_merge( int $term_id, array $components, bool $dry_run ): array { + $filled = array(); + + foreach ( self::ADDRESS_META_KEYS as $field => $meta_key ) { + $existing = (string) get_term_meta( $term_id, $meta_key, true ); + if ( '' !== trim( $existing ) ) { + continue; + } + + $incoming = trim( (string) ( $components[ $field ] ?? '' ) ); + if ( '' === $incoming ) { + continue; + } + + if ( ! $dry_run ) { + update_term_meta( $term_id, $meta_key, $incoming ); + } + + $filled[] = $meta_key; + } + + return $filled; + } + + /** + * Reverse-geocode a `lat,lng` coordinates string into address + * components via Nominatim's /reverse endpoint. + * + * Protected so tests can stub the network call without subclassing + * the whole command — `\Closure::bind` or a small test-subclass is + * the standard pattern. + * + * @param string $coordinates Coordinates as "lat,lng". + * @return array{address:string,city:string,state:string,zip:string,country:string}|null + * Parsed components, or null when the lookup fails or coordinates are + * malformed. + */ + protected function reverse_geocode( string $coordinates ): ?array { + $parts = array_map( 'trim', explode( ',', $coordinates ) ); + if ( count( $parts ) < 2 ) { + return null; + } + + [ $lat, $lon ] = $parts; + if ( '' === $lat || '' === $lon || ! is_numeric( $lat ) || ! is_numeric( $lon ) ) { + return null; + } + + $url = add_query_arg( + array( + 'format' => 'jsonv2', + 'lat' => $lat, + 'lon' => $lon, + 'addressdetails' => '1', + ), + self::NOMINATIM_REVERSE_API + ); + + $result = HttpClient::get( + $url, + array( + 'timeout' => 10, + 'headers' => array( + 'User-Agent' => self::NOMINATIM_USER_AGENT, + ), + 'context' => 'Venue Reverse Geocoding', + ) + ); + + if ( empty( $result['success'] ) ) { + do_action( + 'datamachine_log', + 'warning', + 'Reverse geocoding request failed', + array( + 'error' => $result['error'] ?? 'unknown error', + 'coordinates' => $coordinates, + ) + ); + return null; + } + + // Be nice to Nominatim between successive lookups when a single + // --apply run touches many venues. + usleep( self::RATE_LIMIT_SECONDS * 1_000_000 ); + + $body = is_string( $result['data'] ?? null ) ? $result['data'] : ''; + $data = json_decode( $body, true ); + if ( ! is_array( $data ) || empty( $data['address'] ) ) { + return null; + } + + return $this->parse_address_components( $data ); + } + + /** + * Forward-search Nominatim for `{name} {city}` and return parsed + * address components from the top result. + * + * The caller is responsible for the name-similarity gate; this + * method does NOT reject results on its own. + * + * @param string $name Venue name. + * @param string $city City to scope the search. + * @return array{address:string,city:string,state:string,zip:string,country:string,display_name_short:string}|null + * Parsed components or null on no-match / network failure. + */ + protected function places_lookup( string $name, string $city ): ?array { + $name = trim( html_entity_decode( $name ) ); + $city = trim( html_entity_decode( $city ) ); + + if ( '' === $name || '' === $city ) { + return null; + } + + $query = sprintf( '%s %s', $name, $city ); + + $url = add_query_arg( + array( + 'format' => 'jsonv2', + 'q' => $query, + 'limit' => '1', + 'addressdetails' => '1', + ), + self::NOMINATIM_SEARCH_API + ); + + $result = HttpClient::get( + $url, + array( + 'timeout' => 10, + 'headers' => array( + 'User-Agent' => self::NOMINATIM_USER_AGENT, + ), + 'context' => 'Venue Places Lookup', + ) + ); + + if ( empty( $result['success'] ) ) { + do_action( + 'datamachine_log', + 'warning', + 'Places lookup request failed', + array( + 'error' => $result['error'] ?? 'unknown error', + 'query' => $query, + ) + ); + return null; + } + + usleep( self::RATE_LIMIT_SECONDS * 1_000_000 ); + + $body = is_string( $result['data'] ?? null ) ? $result['data'] : ''; + $data = json_decode( $body, true ); + + if ( ! is_array( $data ) || empty( $data[0] ) || empty( $data[0]['address'] ) ) { + return null; + } + + $parsed = $this->parse_address_components( $data[0] ); + + // Top hit's leading display-name token is what we name-compare + // against. Nominatim returns the full display name with commas; + // the first comma-segment is the POI name (e.g. "Stubb's + // Bar-B-Q, 801 Red River Street, Austin, ..."). + $display = (string) ( $data[0]['display_name'] ?? '' ); + $display_head = strtok( $display, ',' ); + $parsed['display_name_short'] = (string) ( false === $display_head ? '' : $display_head ); + + return $parsed; + } + + /** + * Parse the `address` block of a Nominatim payload (forward or + * reverse) into the five venue meta fields we care about. + * + * Nominatim returns granular components — `house_number`, `road`, + * `suburb`, `neighbourhood`, `city`, `town`, `village`, `state`, + * `postcode`, `country` — that we collapse into our flatter schema: + * + * address = " " (street line only) + * city = address.city || town || village || suburb + * state = address.state + * zip = address.postcode + * country = address.country + * + * Missing components become empty strings so the caller's smart-merge + * loop can decide whether to skip them. + * + * @param array $payload Nominatim response item. + * @return array{address:string,city:string,state:string,zip:string,country:string} + */ + private function parse_address_components( array $payload ): array { + $address_block = is_array( $payload['address'] ?? null ) ? $payload['address'] : array(); + + $house_number = trim( (string) ( $address_block['house_number'] ?? '' ) ); + $road = trim( (string) ( $address_block['road'] ?? '' ) ); + + $street = trim( $house_number . ' ' . $road ); + + // `city` is not always present — Nominatim sometimes returns + // `town` or `village` for smaller localities, and `suburb` as a + // last resort. Take the first non-empty hit. + $city = ''; + foreach ( array( 'city', 'town', 'village', 'suburb', 'hamlet' ) as $key ) { + $value = trim( (string) ( $address_block[ $key ] ?? '' ) ); + if ( '' !== $value ) { + $city = $value; + break; + } + } + + return array( + 'address' => $street, + 'city' => $city, + 'state' => trim( (string) ( $address_block['state'] ?? '' ) ), + 'zip' => trim( (string) ( $address_block['postcode'] ?? '' ) ), + 'country' => trim( (string) ( $address_block['country'] ?? '' ) ), + ); + } +} diff --git a/inc/Cli/Check/CheckOrphanVenuesCommand.php b/inc/Cli/Check/CheckOrphanVenuesCommand.php new file mode 100644 index 0000000..12c26e8 --- /dev/null +++ b/inc/Cli/Check/CheckOrphanVenuesCommand.php @@ -0,0 +1,400 @@ + and skip deletion. + * + * 3. REAL ORPHANS — cache accurate and no flow refs. Default behavior + * is FLAG-NOT-DELETE: stamp _venue_orphan_flagged_at with the + * current Unix timestamp and leave the term in place. The + * operator decides whether to delete later via --delete-orphans. + * + * 4. PROTECTED FROM DELETION even with --delete-orphans: + * - VenueMergeHelper::NO_MERGE_META_KEY (`_venue_no_merge=1`) — a + * general "do not auto-modify this term" opt-out. We treat it as + * an anti-delete signal too. + * - `_venue_orphan_protected_by_flow` set by step 2. + * + * Default `--dry-run`; require `--apply` to commit. `--delete-orphans` + * is opt-in even with `--apply`. + * + * @package DataMachineEvents\Cli\Check + * @since 0.38.0 + */ + +namespace DataMachineEvents\Cli\Check; + +use DataMachineEvents\Core\DuplicateDetection\VenueMergeHelper; + +defined( 'ABSPATH' ) || exit; + +class CheckOrphanVenuesCommand { + + /** + * Term meta key stamped on real orphans the operator should review. + * Value is the current Unix timestamp at the time of flagging. + */ + public const ORPHAN_FLAGGED_META_KEY = '_venue_orphan_flagged_at'; + + /** + * Term meta key stamped on orphans that are referenced by an active + * flow. Value is the flow_id holding the reference. + */ + public const ORPHAN_PROTECTED_BY_FLOW_META_KEY = '_venue_orphan_protected_by_flow'; + + /** + * Audit + repair venue terms with wp_term_taxonomy.count = 0. + * + * ## OPTIONS + * + * [--dry-run] + * : Show what would be flagged/protected/deleted without writing. + * Default behavior — pass --apply to commit changes. + * + * [--apply] + * : Actually perform the flagging / deletion / cache-refresh work. + * + * [--delete-orphans] + * : Opt-in to DELETING orphan venue terms. Without this flag, real + * orphans are only flagged via term meta and remain in place. + * No effect under --dry-run. + * + * [--limit=] + * : Cap the number of orphan candidates processed per run. + * --- + * default: 100 + * --- + * + * [--format=] + * : Output format for the per-term table. + * --- + * default: table + * options: + * - table + * - csv + * - json + * --- + * + * ## EXAMPLES + * + * wp data-machine-events check orphan-venues --dry-run + * wp data-machine-events check orphan-venues --apply + * wp data-machine-events check orphan-venues --apply --delete-orphans + * wp data-machine-events check orphan-venues --dry-run --format=csv + * + * @param array $args Positional arguments. + * @param array $assoc_args Named arguments. + */ + public function __invoke( array $args, array $assoc_args ): void { + $apply = isset( $assoc_args['apply'] ); + $delete_orphans = isset( $assoc_args['delete-orphans'] ); + $limit = max( 1, (int) ( $assoc_args['limit'] ?? 100 ) ); + $format = (string) ( $assoc_args['format'] ?? 'table' ); + + $dry_run = ! $apply; + + $candidates = $this->find_candidates(); + + if ( empty( $candidates ) ) { + \WP_CLI::success( 'No orphan venue terms detected.' ); + return; + } + + \WP_CLI::log( sprintf( 'Detected %d candidate orphan venue term(s).', count( $candidates ) ) ); + + if ( count( $candidates ) > $limit ) { + \WP_CLI::log( sprintf( 'Processing first %d this run (use --limit=N to change).', $limit ) ); + $candidates = array_slice( $candidates, 0, $limit ); + } + + $rows = array(); + + foreach ( $candidates as $term ) { + $rows[] = $this->process_candidate( $term, $dry_run, $delete_orphans ); + } + + \WP_CLI\Utils\format_items( + $format, + $rows, + array( + 'term_id', + 'term_name', + 'action_taken', + 'reason', + ) + ); + + if ( $dry_run ) { + \WP_CLI::log( '' ); + \WP_CLI::log( 'DRY RUN — no changes made. Re-run with --apply to commit.' ); + return; + } + + $summary = array( + 'count_refreshed' => 0, + 'protected_by_flow' => 0, + 'flagged' => 0, + 'deleted' => 0, + 'protected' => 0, + ); + + foreach ( $rows as $row ) { + $action = (string) $row['action_taken']; + if ( isset( $summary[ $action ] ) ) { + ++$summary[ $action ]; + } + } + + \WP_CLI::success( + sprintf( + 'Processed %d term(s): %d cache-refreshed, %d protected by flow, %d flagged, %d deleted, %d protected from deletion.', + count( $rows ), + $summary['count_refreshed'], + $summary['protected_by_flow'], + $summary['flagged'], + $summary['deleted'], + $summary['protected'] + ) + ); + } + + /** + * Find every venue term whose `wp_term_taxonomy.count` is zero. + * Returns WP_Term objects so the caller can read meta + name. + * + * @return \WP_Term[] + */ + private function find_candidates(): array { + $terms = get_terms( + array( + 'taxonomy' => 'venue', + 'hide_empty' => false, + 'number' => 0, + ) + ); + + if ( is_wp_error( $terms ) || empty( $terms ) ) { + return array(); + } + + $candidates = array(); + foreach ( $terms as $term ) { + if ( 0 === (int) $term->count ) { + $candidates[] = $term; + } + } + + return $candidates; + } + + /** + * Process one candidate orphan. Returns one row for the output + * table with the action taken and a short reason. + * + * @param \WP_Term $term Candidate term. + * @param bool $dry_run Skip writes. + * @param bool $delete_orphans Whether --delete-orphans was passed. + * @return array + */ + private function process_candidate( \WP_Term $term, bool $dry_run, bool $delete_orphans ): array { + $row = array( + 'term_id' => (int) $term->term_id, + 'term_name' => (string) $term->name, + 'action_taken' => '', + 'reason' => '', + ); + + // Step 1: Verify the count cache against the real + // term_relationships join. If a real relationship exists, the + // cache is stale — refresh it and exit without flagging. + $real_count = $this->real_relationship_count( (int) $term->term_id ); + + if ( $real_count > 0 ) { + if ( ! $dry_run ) { + wp_update_term_count_now( array( (int) $term->term_taxonomy_id ), 'venue' ); + } + $row['action_taken'] = 'count_refreshed'; + $row['reason'] = sprintf( 'stale cache: %d real relationships found', $real_count ); + return $row; + } + + // Step 2: Active-flow protection. A flow holding a reference to + // this term_id means an operator wired it intentionally — do + // not delete even if --delete-orphans is set. + $flow_id = $this->find_flow_referencing( (int) $term->term_id ); + + if ( null !== $flow_id ) { + if ( ! $dry_run ) { + update_term_meta( + (int) $term->term_id, + self::ORPHAN_PROTECTED_BY_FLOW_META_KEY, + $flow_id + ); + } + $row['action_taken'] = 'protected_by_flow'; + $row['reason'] = sprintf( 'referenced by flow_id %d', $flow_id ); + return $row; + } + + // Step 3: --delete-orphans protections (only relevant if the + // operator actually wants to delete). We check these BEFORE + // flagging so a term flagged in a previous run that an operator + // has since marked _venue_no_merge does not get force-deleted + // on a later --apply --delete-orphans run. + if ( $delete_orphans ) { + $no_merge = (int) get_term_meta( + (int) $term->term_id, + VenueMergeHelper::NO_MERGE_META_KEY, + true + ); + + $existing_flow_protection = (int) get_term_meta( + (int) $term->term_id, + self::ORPHAN_PROTECTED_BY_FLOW_META_KEY, + true + ); + + if ( $no_merge > 0 ) { + $row['action_taken'] = 'protected'; + $row['reason'] = 'opt-out flag set (_venue_no_merge)'; + return $row; + } + + if ( $existing_flow_protection > 0 ) { + $row['action_taken'] = 'protected'; + $row['reason'] = sprintf( + 'previously flagged as flow-protected (flow_id %d)', + $existing_flow_protection + ); + return $row; + } + + // Real orphan + opt-in delete + no protections → delete. + if ( ! $dry_run ) { + $deleted = wp_delete_term( (int) $term->term_id, 'venue' ); + if ( is_wp_error( $deleted ) || true !== $deleted ) { + $row['action_taken'] = 'flagged'; + $row['reason'] = 'wp_delete_term failed; falling back to flag'; + + update_term_meta( + (int) $term->term_id, + self::ORPHAN_FLAGGED_META_KEY, + time() + ); + return $row; + } + } + + $row['action_taken'] = 'deleted'; + $row['reason'] = 'real orphan; --delete-orphans opt-in'; + return $row; + } + + // Step 4: Default — flag the term, leave it in place. + if ( ! $dry_run ) { + update_term_meta( + (int) $term->term_id, + self::ORPHAN_FLAGGED_META_KEY, + time() + ); + } + + $row['action_taken'] = 'flagged'; + $row['reason'] = 'real orphan; flag-only (operator decides deletion)'; + return $row; + } + + /** + * Query the real `wp_term_relationships` count for a term. Bypasses + * the cached `wp_term_taxonomy.count`. + * + * @param int $term_id Venue term ID. + * @return int Real relationship count. + */ + private function real_relationship_count( int $term_id ): int { + global $wpdb; + + $tt_id = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT term_taxonomy_id FROM {$wpdb->term_taxonomy} + WHERE term_id = %d AND taxonomy = 'venue'", + $term_id + ) + ); + + if ( $tt_id <= 0 ) { + return 0; + } + + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->term_relationships} + WHERE term_taxonomy_id = %d", + $tt_id + ) + ); + } + + /** + * Find the first flow whose flow_config JSON references this term_id + * via a `"venue":""` field (flat or nested). Returns the + * flow_id, or null when no flow references the term. + * + * Mirrors the LIKE-based discovery in + * VenueMergeHelper::reassign_flow_handler_configs() so the protection + * matches the exact set of flow shapes the merge primitive rewrites. + * + * The flows table lives in the Data Machine core schema and is not + * guaranteed to exist in unit-test environments; this method returns + * null if the table is absent. + * + * @param int $term_id Venue term ID. + * @return int|null Flow ID that references this term, or null. + */ + private function find_flow_referencing( int $term_id ): ?int { + global $wpdb; + + $table = $wpdb->prefix . 'datamachine_flows'; + $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + if ( $exists !== $table ) { + return null; + } + + $as_string = (string) $term_id; + + // Two shapes — quoted ("venue":"123") for handler_config that + // stores ids as strings (the common case) and unquoted + // ("venue":123) for any future shape that stores ints. + $flow_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT flow_id FROM {$table} + WHERE flow_config LIKE %s + OR flow_config LIKE %s + ORDER BY flow_id ASC + LIMIT 1", + '%"venue":"' . $wpdb->esc_like( $as_string ) . '"%', + '%"venue":' . $wpdb->esc_like( $as_string ) . '%' + ) + ); + + return $flow_id ? (int) $flow_id : null; + } +} diff --git a/tests/Unit/CheckMissingVenueAddressesCommandTest.php b/tests/Unit/CheckMissingVenueAddressesCommandTest.php new file mode 100644 index 0000000..23958ae --- /dev/null +++ b/tests/Unit/CheckMissingVenueAddressesCommandTest.php @@ -0,0 +1,260 @@ +|null> */ + public array $reverse_responses = array(); + + /** @var array|null> */ + public array $places_responses = array(); + + /** @var array */ + public array $reverse_calls = array(); + + /** @var array */ + public array $places_calls = array(); + + protected function reverse_geocode( string $coordinates ): ?array { + $this->reverse_calls[] = $coordinates; + return $this->reverse_responses[ $coordinates ] ?? null; + } + + protected function places_lookup( string $name, string $city ): ?array { + $key = $name . '|' . $city; + $this->places_calls[] = $key; + return $this->places_responses[ $key ] ?? null; + } +} + +class CheckMissingVenueAddressesCommandTest extends WP_UnitTestCase { + + public function setUp(): void { + parent::setUp(); + + if ( ! taxonomy_exists( 'venue' ) ) { + Venue_Taxonomy::register(); + } + } + + private function make_venue( string $name, array $meta = array() ): int { + $term = wp_insert_term( $name, 'venue' ); + $this->assertNotInstanceOf( \WP_Error::class, $term ); + + $term_id = (int) $term['term_id']; + foreach ( $meta as $key => $value ) { + update_term_meta( $term_id, $key, $value ); + } + + return $term_id; + } + + private function run( StubbedMissingVenueAddressesCommand $cmd, array $assoc_args ): void { + ob_start(); + $cmd( array(), $assoc_args ); + ob_end_clean(); + } + + // --------------------------------------------------------------------- + + public function test_dry_run_reports_count_without_writes(): void { + // Three candidates (no address) — two with coords, one with neither. + $with_coords_a = $this->make_venue( + 'Venue A', + array( '_venue_coordinates' => '30.2672,-97.7431' ) + ); + $with_coords_b = $this->make_venue( + 'Venue B', + array( '_venue_coordinates' => '40.7128,-74.0060' ) + ); + $bare = $this->make_venue( 'Venue C' ); + + // Distractor: an already-addressed venue must not be processed. + $already = $this->make_venue( + 'Already Addressed', + array( '_venue_address' => '123 Main St' ) + ); + + $cmd = new StubbedMissingVenueAddressesCommand(); + $cmd->reverse_responses['30.2672,-97.7431'] = array( + 'address' => '801 Red River St', + 'city' => 'Austin', + 'state' => 'Texas', + 'zip' => '78701', + 'country' => 'United States', + ); + // Note: we intentionally route through --dry-run; the stub will + // be called but no meta writes must result. + + $this->run( $cmd, array( 'dry-run' => true ) ); + + $this->assertSame( '', get_term_meta( $with_coords_a, '_venue_address', true ) ); + $this->assertSame( '', get_term_meta( $with_coords_b, '_venue_address', true ) ); + $this->assertSame( '', get_term_meta( $bare, '_venue_address', true ) ); + $this->assertSame( '123 Main St', get_term_meta( $already, '_venue_address', true ) ); + } + + public function test_apply_fills_from_coordinates(): void { + $term_id = $this->make_venue( + 'Stubbs Bar-B-Q', + array( '_venue_coordinates' => '30.2672,-97.7431' ) + ); + + $cmd = new StubbedMissingVenueAddressesCommand(); + $cmd->reverse_responses['30.2672,-97.7431'] = array( + 'address' => '801 Red River St', + 'city' => 'Austin', + 'state' => 'Texas', + 'zip' => '78701', + 'country' => 'United States', + ); + + $this->run( $cmd, array( 'apply' => true ) ); + + $this->assertSame( '801 Red River St', get_term_meta( $term_id, '_venue_address', true ) ); + $this->assertSame( 'Austin', get_term_meta( $term_id, '_venue_city', true ) ); + $this->assertSame( 'Texas', get_term_meta( $term_id, '_venue_state', true ) ); + $this->assertSame( '78701', get_term_meta( $term_id, '_venue_zip', true ) ); + $this->assertSame( 'United States', get_term_meta( $term_id, '_venue_country', true ) ); + + // Reverse-geocode was called once with the exact stored coords. + $this->assertSame( array( '30.2672,-97.7431' ), $cmd->reverse_calls ); + // Places lookup was NOT called — coordinates path succeeded. + $this->assertSame( array(), $cmd->places_calls ); + } + + public function test_apply_falls_back_to_places_search_when_no_coords(): void { + $term_id = $this->make_venue( + 'Stubbs Bar-B-Q', + array( '_venue_city' => 'Austin' ) + ); + + $cmd = new StubbedMissingVenueAddressesCommand(); + // Places lookup returns a high-similarity match for the term name. + $cmd->places_responses[ 'Stubbs Bar-B-Q|Austin' ] = array( + 'address' => '801 Red River St', + 'city' => 'Austin', + 'state' => 'Texas', + 'zip' => '78701', + 'country' => 'United States', + 'display_name_short' => 'Stubbs Bar-B-Q', + ); + + $this->run( $cmd, array( 'apply' => true ) ); + + $this->assertSame( '801 Red River St', get_term_meta( $term_id, '_venue_address', true ) ); + $this->assertSame( 'Austin', get_term_meta( $term_id, '_venue_city', true ) ); + $this->assertSame( 'Texas', get_term_meta( $term_id, '_venue_state', true ) ); + $this->assertSame( '78701', get_term_meta( $term_id, '_venue_zip', true ) ); + + // Reverse-geocode was NOT called — no coordinates available. + $this->assertSame( array(), $cmd->reverse_calls ); + // Places lookup was called with the (name,city) key. + $this->assertSame( array( 'Stubbs Bar-B-Q|Austin' ), $cmd->places_calls ); + } + + public function test_smart_merge_does_not_overwrite_existing_fields(): void { + $term_id = $this->make_venue( + 'Stubbs Bar-B-Q', + array( + // Pre-curated city the operator wants preserved. + '_venue_city' => 'Existing City', + '_venue_coordinates' => '30.2672,-97.7431', + ) + ); + + $cmd = new StubbedMissingVenueAddressesCommand(); + // Reverse-geocode reports a DIFFERENT city. The pre-curated value + // must survive. + $cmd->reverse_responses['30.2672,-97.7431'] = array( + 'address' => '801 Red River St', + 'city' => 'Austin', + 'state' => 'Texas', + 'zip' => '78701', + 'country' => 'United States', + ); + + $this->run( $cmd, array( 'apply' => true ) ); + + // Address WAS filled (was empty). + $this->assertSame( '801 Red River St', get_term_meta( $term_id, '_venue_address', true ) ); + // City was NOT overwritten. + $this->assertSame( 'Existing City', get_term_meta( $term_id, '_venue_city', true ) ); + // Other empty fields were filled. + $this->assertSame( 'Texas', get_term_meta( $term_id, '_venue_state', true ) ); + $this->assertSame( '78701', get_term_meta( $term_id, '_venue_zip', true ) ); + } + + public function test_residue_reports_no_repair_possible(): void { + $term_id = $this->make_venue( 'Phantom Venue' ); + + $cmd = new StubbedMissingVenueAddressesCommand(); + $this->run( $cmd, array( 'apply' => true ) ); + + // No writes. + $this->assertSame( '', get_term_meta( $term_id, '_venue_address', true ) ); + $this->assertSame( '', get_term_meta( $term_id, '_venue_city', true ) ); + + // Neither lookup was attempted — no coords AND no city means + // the command short-circuits to the residue path. + $this->assertSame( array(), $cmd->reverse_calls ); + $this->assertSame( array(), $cmd->places_calls ); + } + + public function test_places_lookup_rejects_low_name_similarity(): void { + // Term named "The Local Bar" in Austin. Places lookup returns + // "Texas Music Theater" — zero meaningful token overlap, must + // be rejected by VenueMergeHelper::names_are_similar guard. + $term_id = $this->make_venue( + 'The Local Bar', + array( '_venue_city' => 'Austin' ) + ); + + $cmd = new StubbedMissingVenueAddressesCommand(); + $cmd->places_responses[ 'The Local Bar|Austin' ] = array( + 'address' => '208 Nueces St', + 'city' => 'Austin', + 'state' => 'Texas', + 'zip' => '78701', + 'country' => 'United States', + 'display_name_short' => 'Texas Music Theater', + ); + + $this->run( $cmd, array( 'apply' => true ) ); + + // Lookup was attempted but rejected — no writes. + $this->assertSame( '', get_term_meta( $term_id, '_venue_address', true ) ); + $this->assertSame( '', get_term_meta( $term_id, '_venue_state', true ) ); + $this->assertSame( '', get_term_meta( $term_id, '_venue_zip', true ) ); + + // Pre-existing city untouched (was the only non-empty field). + $this->assertSame( 'Austin', get_term_meta( $term_id, '_venue_city', true ) ); + + // Lookup WAS called — the rejection happened after the network round-trip. + $this->assertSame( array( 'The Local Bar|Austin' ), $cmd->places_calls ); + } +} diff --git a/tests/Unit/CheckOrphanVenuesCommandTest.php b/tests/Unit/CheckOrphanVenuesCommandTest.php new file mode 100644 index 0000000..cebd060 --- /dev/null +++ b/tests/Unit/CheckOrphanVenuesCommandTest.php @@ -0,0 +1,298 @@ +ensure_flows_table(); + } + + private function ensure_flows_table(): void { + global $wpdb; + + $table = $wpdb->prefix . 'datamachine_flows'; + $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + + if ( $exists === $table ) { + $wpdb->query( "TRUNCATE TABLE {$table}" ); + return; + } + + $wpdb->query( + "CREATE TABLE {$table} ( + flow_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + pipeline_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + flow_name VARCHAR(255) NOT NULL DEFAULT '', + flow_config LONGTEXT NOT NULL, + PRIMARY KEY (flow_id) + )" + ); + } + + private function make_venue( string $name, array $meta = array() ): int { + $term = wp_insert_term( $name, 'venue' ); + $this->assertNotInstanceOf( \WP_Error::class, $term ); + + $term_id = (int) $term['term_id']; + foreach ( $meta as $key => $value ) { + update_term_meta( $term_id, $key, $value ); + } + + return $term_id; + } + + private function make_event_with_venue( string $title, int $venue_term_id ): int { + $post_id = wp_insert_post( + array( + 'post_type' => Event_Post_Type::POST_TYPE, + 'post_title' => $title, + 'post_status' => 'publish', + ) + ); + + $this->assertIsInt( $post_id ); + wp_set_object_terms( $post_id, array( $venue_term_id ), 'venue', false ); + + return $post_id; + } + + private function insert_flow( string $flow_config_json ): int { + global $wpdb; + + $wpdb->insert( + $wpdb->prefix . 'datamachine_flows', + array( + 'pipeline_id' => 1, + 'flow_name' => 'test flow', + 'flow_config' => $flow_config_json, + ), + array( '%d', '%s', '%s' ) + ); + + return (int) $wpdb->insert_id; + } + + /** + * Make a venue term whose taxonomy.count cache value is wrong: + * count=0 but a real `wp_term_relationships` row exists. + * + * Returns array(term_id, post_id). + */ + private function make_stale_cached_orphan( string $name ): array { + global $wpdb; + + $term_id = $this->make_venue( $name ); + $post_id = $this->make_event_with_venue( 'Real event', $term_id ); + + // Real relationship now exists and count was correctly set to 1 + // by wp_set_object_terms. Force the cache to lie about it. + $wpdb->update( + $wpdb->term_taxonomy, + array( 'count' => 0 ), + array( + 'term_id' => $term_id, + 'taxonomy' => 'venue', + ), + array( '%d' ), + array( '%d', '%s' ) + ); + + clean_term_cache( array( $term_id ), 'venue' ); + + return array( $term_id, $post_id ); + } + + private function run( CheckOrphanVenuesCommand $cmd, array $assoc_args ): void { + ob_start(); + $cmd( array(), $assoc_args ); + ob_end_clean(); + } + + // --------------------------------------------------------------------- + + public function test_dry_run_lists_orphans(): void { + $a = $this->make_venue( 'Orphan A' ); + $b = $this->make_venue( 'Orphan B' ); + $c = $this->make_venue( 'Orphan C' ); + + // Distractor: a venue with real usage must not be touched. + $active = $this->make_venue( 'Active Venue' ); + $this->make_event_with_venue( 'Real event', $active ); + + $cmd = new CheckOrphanVenuesCommand(); + $this->run( $cmd, array( 'dry-run' => true ) ); + + // No flag meta written on any candidate. + foreach ( array( $a, $b, $c ) as $term_id ) { + $this->assertSame( + '', + (string) get_term_meta( $term_id, CheckOrphanVenuesCommand::ORPHAN_FLAGGED_META_KEY, true ) + ); + } + + // Terms still exist. + $this->assertNotNull( get_term( $a, 'venue' ) ); + $this->assertNotNull( get_term( $b, 'venue' ) ); + $this->assertNotNull( get_term( $c, 'venue' ) ); + } + + public function test_refreshes_stale_count_cache_before_deciding(): void { + [ $term_id ] = $this->make_stale_cached_orphan( 'Stale Cache Venue' ); + + $cmd = new CheckOrphanVenuesCommand(); + $this->run( $cmd, array( 'apply' => true ) ); + + // Term must NOT be flagged as orphan. + $this->assertSame( + '', + (string) get_term_meta( $term_id, CheckOrphanVenuesCommand::ORPHAN_FLAGGED_META_KEY, true ) + ); + + // Term still exists. + $this->assertNotNull( get_term( $term_id, 'venue' ) ); + + // Cache should now report the correct count (>=1). + $refreshed = get_term( $term_id, 'venue' ); + $this->assertGreaterThanOrEqual( 1, (int) $refreshed->count ); + } + + public function test_protects_orphan_referenced_by_active_flow(): void { + $term_id = $this->make_venue( 'Flow-Referenced Orphan' ); + + $flow_id = $this->insert_flow( + wp_json_encode( + array( + 'step_1' => array( + 'handler_config' => array( + 'venue' => (string) $term_id, + ), + ), + ) + ) + ); + + $cmd = new CheckOrphanVenuesCommand(); + $this->run( $cmd, array( 'apply' => true ) ); + + // protected-by-flow meta written, flag-at meta NOT written. + $this->assertSame( + (string) $flow_id, + (string) get_term_meta( $term_id, CheckOrphanVenuesCommand::ORPHAN_PROTECTED_BY_FLOW_META_KEY, true ) + ); + $this->assertSame( + '', + (string) get_term_meta( $term_id, CheckOrphanVenuesCommand::ORPHAN_FLAGGED_META_KEY, true ) + ); + + // Term still exists. + $this->assertNotNull( get_term( $term_id, 'venue' ) ); + } + + public function test_flags_real_orphan_without_delete_orphans(): void { + $term_id = $this->make_venue( 'Plain Orphan' ); + + $cmd = new CheckOrphanVenuesCommand(); + $this->run( $cmd, array( 'apply' => true ) ); + + // Flag meta written with a current-ish timestamp. + $flagged_at = (int) get_term_meta( + $term_id, + CheckOrphanVenuesCommand::ORPHAN_FLAGGED_META_KEY, + true + ); + $this->assertGreaterThan( time() - 60, $flagged_at ); + + // Term still exists. + $this->assertNotNull( get_term( $term_id, 'venue' ) ); + } + + public function test_deletes_real_orphan_with_delete_orphans(): void { + $term_id = $this->make_venue( 'Deletable Orphan' ); + + $cmd = new CheckOrphanVenuesCommand(); + $this->run( + $cmd, + array( + 'apply' => true, + 'delete-orphans' => true, + ) + ); + + // Term deleted. + $this->assertNull( get_term( $term_id, 'venue' ) ); + } + + public function test_does_not_delete_protected_orphans_even_with_delete_orphans(): void { + // Two protections to assert: opt-out flag and flow-protected meta. + $opt_out_term = $this->make_venue( + 'Opt-Out Orphan', + array( VenueMergeHelper::NO_MERGE_META_KEY => '1' ) + ); + + $flow_protected_term = $this->make_venue( + 'Pre-Flagged Flow-Protected Orphan', + array( CheckOrphanVenuesCommand::ORPHAN_PROTECTED_BY_FLOW_META_KEY => '99' ) + ); + + $cmd = new CheckOrphanVenuesCommand(); + $this->run( + $cmd, + array( + 'apply' => true, + 'delete-orphans' => true, + ) + ); + + // Both terms must still exist. + $this->assertNotNull( + get_term( $opt_out_term, 'venue' ), + '_venue_no_merge must protect from --delete-orphans' + ); + $this->assertNotNull( + get_term( $flow_protected_term, 'venue' ), + '_venue_orphan_protected_by_flow must protect from --delete-orphans' + ); + + // Neither got the post-hoc flagged meta either — they were + // handled by the protection branch which short-circuits before + // the delete OR flag steps. + $this->assertSame( + '', + (string) get_term_meta( $opt_out_term, CheckOrphanVenuesCommand::ORPHAN_FLAGGED_META_KEY, true ) + ); + $this->assertSame( + '', + (string) get_term_meta( $flow_protected_term, CheckOrphanVenuesCommand::ORPHAN_FLAGGED_META_KEY, true ) + ); + } +} diff --git a/tests/Unit/VenueStatsAbilitiesTest.php b/tests/Unit/VenueStatsAbilitiesTest.php new file mode 100644 index 0000000..44e366f --- /dev/null +++ b/tests/Unit/VenueStatsAbilitiesTest.php @@ -0,0 +1,109 @@ +assertNotInstanceOf( \WP_Error::class, $term ); + $term_id = (int) $term['term_id']; + + foreach ( $meta as $key => $value ) { + update_term_meta( $term_id, $key, $value ); + } + + if ( $faked_count > 0 ) { + $wpdb->update( + $wpdb->term_taxonomy, + array( 'count' => $faked_count ), + array( + 'term_id' => $term_id, + 'taxonomy' => 'venue', + ), + array( '%d' ), + array( '%d', '%s' ) + ); + clean_term_cache( array( $term_id ), 'venue' ); + } + + return $term_id; + } + + public function test_venue_stats_ability_returns_expected_shape(): void { + // Seed: 5 venue terms total + // - 2 with no _venue_address (counts as no_address) + // - 2 with addresses AND count=2 (NOT orphans, NOT missing address) + // - 1 with no address AND count=0 (counts in BOTH no_address and orphans) + // + // Expected: + // no_address = 3 + // orphans = 3 (2 of the seeded no-address + 1 of the joint) + // wait — we need to re-tally. Two seeded "no_address only" have count=0 + // because nothing was assigned. The joint one is also count=0. So + // orphans = 3 (all the no-address ones). The two addressed ones have + // count=2 explicitly faked. + // total = 5 + + $this->make_venue( 'No Address A' ); + $this->make_venue( 'No Address B' ); + $this->make_venue( 'Joint (no addr + orphan)' ); + + $this->make_venue( + 'Addressed Active A', + array( '_venue_address' => '100 Main St' ), + 2 + ); + $this->make_venue( + 'Addressed Active B', + array( '_venue_address' => '200 Oak Ave' ), + 2 + ); + + $ability = new VenueStatsAbilities(); + $result = $ability->executeVenueStats( array() ); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'no_address', $result ); + $this->assertArrayHasKey( 'orphans', $result ); + $this->assertArrayHasKey( 'total', $result ); + $this->assertArrayHasKey( 'queried_at', $result ); + + $this->assertSame( 5, $result['total'] ); + $this->assertSame( 3, $result['no_address'] ); + $this->assertSame( 3, $result['orphans'] ); + + // queried_at is a recent unix timestamp. + $this->assertIsInt( $result['queried_at'] ); + $this->assertGreaterThan( time() - 60, $result['queried_at'] ); + $this->assertLessThanOrEqual( time(), $result['queried_at'] ); + } +}