diff --git a/.gitignore b/.gitignore index 7a9ae7a..c9901a0 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ Thumbs.db node_modules/ frontend/test-results/ frontend/playwright-report/ +/test-results/ +/playwright-report/ # Vercel .vercel/ diff --git a/backend/api_core.py b/backend/api_core.py index ccb6635..0117be6 100644 --- a/backend/api_core.py +++ b/backend/api_core.py @@ -2602,12 +2602,62 @@ def validated_order_deadline(value, now=None): return parsed.strftime("%Y-%m-%dT%H:%M:%SZ") -def service_order_deadline(delivery_time_days, now=None): - """Derive a fixed-service deadline from the seller's published delivery promise.""" +DEFAULT_FIXED_SERVICE_DELIVERY_DAYS = 7 + + +def validated_service_delivery_time(delivery_time_days, required=False): + """Validate seller-published delivery metadata; fixed checkout requires a bounded value.""" + if delivery_time_days is None: + if required: + raise ValueError("delivery_time_days is required for fixed pricing") + return None if isinstance(delivery_time_days, bool) or not isinstance(delivery_time_days, int): - raise ValueError("Fixed services require delivery_time_days between 1 and 365") + raise ValueError("delivery_time_days must be an integer between 1 and 365") if not 1 <= delivery_time_days <= 365: - raise ValueError("Fixed services require delivery_time_days between 1 and 365") + raise ValueError("delivery_time_days must be an integer between 1 and 365") + return delivery_time_days + + +def validate_service_pricing_state(pricing_type, price, hourly_rate): + """Reject active service states that cannot produce a positive checkout amount.""" + if pricing_type not in ('fixed', 'hourly', 'custom'): + raise ValueError("pricing_type must be fixed, hourly, or custom") + + if pricing_type == 'fixed': + field_name, value, required_message = ( + "price", price, "price required for fixed pricing" + ) + elif pricing_type == 'hourly': + field_name, value, required_message = ( + "hourly_rate", hourly_rate, "hourly_rate required for hourly pricing" + ) + else: + return + + if value is None or value == "": + raise ValueError(required_message) + try: + amount_cents = money_to_cents(value, f"service {field_name}") + except (TypeError, ValueError) as exc: + raise ValueError( + f"{field_name} must be a positive amount with no more than two decimal places" + ) from exc + if amount_cents <= 0: + raise ValueError(f"{field_name} must be greater than zero") + + +def validate_ai_api_endpoint(provider_type, fulfillment_type, api_endpoint): + """Keep AI/API fulfillment listings executable across create and update paths.""" + if provider_type == 'ai' and fulfillment_type == 'api': + if not isinstance(api_endpoint, str) or not api_endpoint.strip(): + raise ValueError("api_endpoint required for AI API services") + + +def service_order_deadline(delivery_time_days, now=None): + """Derive a fixed-service deadline from the seller's published delivery promise.""" + delivery_time_days = validated_service_delivery_time(delivery_time_days, required=True) + if delivery_time_days is None: # Required validation above narrows the runtime contract. + raise ValueError("delivery_time_days is required for fixed pricing") current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc).replace(microsecond=0) return (current + timedelta(days=delivery_time_days)).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -8342,20 +8392,35 @@ def _handle_routes(db): if not safe: return error_response(f"Service rejected: {msg}", 422) - # Ensure worker profile exists (payout can be set up later) - ensure_worker_profile(db, user['id']) - pricing_type = body.get("pricing_type", "fixed") - if pricing_type not in ('fixed', 'hourly', 'custom'): - return error_response("pricing_type must be fixed, hourly, or custom") + delivery_input = body.get("delivery_time_days") + if pricing_type == 'fixed' and delivery_input is None: + # Preserve the pre-existing direct-API contract while making new rows + # explicit: legacy checkout already used this bounded seven-day default. + delivery_input = DEFAULT_FIXED_SERVICE_DELIVERY_DAYS + try: + delivery_time_days = validated_service_delivery_time( + delivery_input, required=pricing_type == 'fixed' + ) + except ValueError as exc: + return error_response(str(exc)) price = body.get("price") hourly_rate = body.get("hourly_rate") + try: + validate_service_pricing_state(pricing_type, price, hourly_rate) + except ValueError as exc: + return error_response(str(exc)) - if pricing_type == 'fixed' and not price: - return error_response("price required for fixed pricing") - if pricing_type == 'hourly' and not hourly_rate: - return error_response("hourly_rate required for hourly pricing") + # Keep only the amount family used by checkout; custom prices are supplied + # by the employer when a custom service is ordered. + if pricing_type == 'fixed': + hourly_rate = None + elif pricing_type == 'hourly': + price = None + else: + price = None + hourly_rate = None tags = body.get("tags", []) images = body.get("images", []) @@ -8372,8 +8437,13 @@ def _handle_routes(db): ai_model = body.get("ai_model", "") avg_response_time = body.get("avg_response_time", "") - if provider_type == 'ai' and fulfillment_type == 'api' and not api_endpoint: - return error_response("api_endpoint required for API-fulfilled AI services") + try: + validate_ai_api_endpoint(provider_type, fulfillment_type, api_endpoint) + except ValueError as exc: + return error_response(str(exc)) + + # Ensure the worker profile only after the complete request is valid. + ensure_worker_profile(db, user['id']) cursor = db.execute( """INSERT INTO services @@ -8383,7 +8453,7 @@ def _handle_routes(db): VALUES (?,?,?,?,?,?,?,?,?,?,?,'active',?,?,?,?,?)""", [user['id'], body['title'], body['description'], body['category'], pricing_type, price, hourly_rate, - body.get("delivery_time_days"), + delivery_time_days, body.get("includes", ""), json.dumps(tags) if isinstance(tags, list) else tags, json.dumps(images) if isinstance(images, list) else images, @@ -8422,6 +8492,55 @@ def _handle_routes(db): if not safe: return error_response(f"Service update rejected: {msg}", 422) + pricing_fields_changed = any( + field in body for field in ('pricing_type', 'price', 'hourly_rate') + ) + if pricing_fields_changed: + effective_pricing_type = body.get('pricing_type', svc['pricing_type']) + effective_price = body.get('price', svc['price']) + effective_hourly_rate = body.get('hourly_rate', svc['hourly_rate']) + try: + validate_service_pricing_state( + effective_pricing_type, effective_price, effective_hourly_rate + ) + except ValueError as exc: + return error_response(str(exc)) + + # Remove stale amounts from the inactive pricing family so later + # transitions cannot silently reuse obsolete checkout terms. + if effective_pricing_type == 'fixed': + body['hourly_rate'] = None + elif effective_pricing_type == 'hourly': + body['price'] = None + else: + body['price'] = None + body['hourly_rate'] = None + + if 'delivery_time_days' in body or 'pricing_type' in body: + effective_pricing_type = body.get('pricing_type', svc['pricing_type']) + effective_delivery_time = body.get('delivery_time_days', svc['delivery_time_days']) + if effective_pricing_type not in ('fixed', 'hourly', 'custom'): + return error_response("pricing_type must be fixed, hourly, or custom") + try: + validated_service_delivery_time( + effective_delivery_time, required=effective_pricing_type == 'fixed' + ) + except ValueError as exc: + return error_response(str(exc)) + + if any(field in body for field in ('provider_type', 'fulfillment_type', 'api_endpoint')): + effective_provider_type = body.get('provider_type', svc['provider_type']) + effective_fulfillment_type = body.get('fulfillment_type', svc['fulfillment_type']) + effective_api_endpoint = body.get('api_endpoint', svc['api_endpoint']) + try: + validate_ai_api_endpoint( + effective_provider_type, + effective_fulfillment_type, + effective_api_endpoint, + ) + except ValueError as exc: + return error_response(str(exc)) + updates = [] vals = [] for field in ['title', 'description', 'category', 'pricing_type', 'price', 'hourly_rate', @@ -8432,6 +8551,10 @@ def _handle_routes(db): return error_response("Invalid category") if field == 'status' and body[field] not in ('active', 'paused', 'removed'): return error_response("Invalid status") + if field == 'provider_type' and body[field] not in ('human', 'ai'): + return error_response("provider_type must be 'human' or 'ai'") + if field == 'fulfillment_type' and body[field] not in ('manual', 'api'): + return error_response("fulfillment_type must be 'manual' or 'api'") updates.append(f"{field} = ?") vals.append(body[field]) for field in ['tags', 'images']: @@ -9214,7 +9337,11 @@ def _handle_routes(db): # Legacy fixed listings predate required delivery promises. Preserve # checkout with a conservative bounded default; current listings use # the seller's published delivery_time_days. - delivery_days = svc['delivery_time_days'] if svc['delivery_time_days'] is not None else 7 + delivery_days = ( + svc['delivery_time_days'] + if svc['delivery_time_days'] is not None + else DEFAULT_FIXED_SERVICE_DELIVERY_DAYS + ) deadline_at = service_order_deadline(delivery_days) except ValueError as e: return error_response(str(e), 409) diff --git a/backend/test_deep_audit_regressions.py b/backend/test_deep_audit_regressions.py index 162eaf8..f653e2b 100644 --- a/backend/test_deep_audit_regressions.py +++ b/backend/test_deep_audit_regressions.py @@ -52,6 +52,216 @@ def tearDown(self): os.environ.pop("DATABASE_PATH", None) os.environ.pop("DISABLE_AUTO_SEED", None) + def _request_api(self, method, path, payload=None, token=""): + body = json.dumps(payload or {}) + for cached in ("body_cache", "raw_body"): + if hasattr(self.module._request_ctx, cached): + delattr(self.module._request_ctx, cached) + self.module._request_ctx.request_method = method + self.module._request_ctx.path_info = path + self.module._request_ctx.query_string = "" + self.module._request_ctx.http_authorization = f"Bearer {token}" if token else "" + self.module._request_ctx.http_x_api_key = "" + self.module._request_ctx.stdin_data = body + self.module._request_ctx.stdin_data_raw = body.encode("utf-8") + self.module._request_ctx.content_type = "application/json" + self.module._request_ctx.content_length = str(len(self.module._request_ctx.stdin_data_raw)) + self.module._request_ctx.remote_addr = "127.0.0.1" + with contextlib.redirect_stdout(io.StringIO()) as out: + self.module.handle_request() + return parse_cgi_output(out.getvalue()) + + def test_service_mutations_enforce_checkout_compatible_delivery_days(self): + token = "tok-service-delivery" + db = self.module.get_db() + try: + db.execute( + "INSERT INTO users (id,email,password_hash,name) VALUES (1,'worker@example.com','x','Worker')" + ) + db.execute( + "INSERT INTO sessions (user_id,token,expires_at) VALUES (1,?,datetime('now','+1 day'))", + [token], + ) + db.commit() + finally: + db.close() + + fixed = { + "title": "Bounded review", + "description": "Review one bounded artifact and return evidence.", + "category": "testing", + "pricing_type": "fixed", + "price": 99, + } + status, compatible_fixed = self._request_api("POST", "/services", fixed, token) + self.assertEqual(status, 201, compatible_fixed) + self.assertEqual(compatible_fixed["delivery_time_days"], 7) + + for invalid in (0, -1, 366, "1", True): + status, result = self._request_api( + "POST", "/services", {**fixed, "delivery_time_days": invalid}, token + ) + with self.subTest(invalid_delivery=invalid): + self.assertEqual(status, 400, result) + self.assertIn("delivery_time_days", result.get("error", "")) + + for invalid_price in (None, 0, -1, "1.001", "invalid", True): + payload = {**fixed, "delivery_time_days": 1, "price": invalid_price} + status, result = self._request_api("POST", "/services", payload, token) + with self.subTest(invalid_fixed_price=invalid_price): + self.assertEqual(status, 400, result) + self.assertIn("price", result.get("error", "").lower()) + + status, created = self._request_api( + "POST", "/services", {**fixed, "delivery_time_days": 1}, token + ) + self.assertEqual(status, 201, created) + self.assertEqual(created["delivery_time_days"], 1) + + status, result = self._request_api( + "PUT", f"/services/{created['id']}", {"delivery_time_days": 0}, token + ) + self.assertEqual(status, 400, result) + self.assertIn("delivery_time_days", result.get("error", "")) + + status, updated = self._request_api( + "PUT", f"/services/{created['id']}", {"delivery_time_days": 365}, token + ) + self.assertEqual(status, 200, updated) + self.assertEqual(updated["delivery_time_days"], 365) + + status, result = self._request_api( + "PUT", f"/services/{created['id']}", {"provider_type": "unknown"}, token + ) + self.assertEqual(status, 400, result) + self.assertIn("provider_type", result.get("error", "")) + + status, result = self._request_api( + "PUT", + f"/services/{created['id']}", + {"provider_type": "ai", "fulfillment_type": "api"}, + token, + ) + self.assertEqual(status, 400, result) + self.assertIn("api_endpoint", result.get("error", "")) + + status, updated_ai = self._request_api( + "PUT", + f"/services/{created['id']}", + { + "provider_type": "ai", + "fulfillment_type": "api", + "api_endpoint": "https://provider.example/process", + }, + token, + ) + self.assertEqual(status, 200, updated_ai) + self.assertEqual(updated_ai["api_endpoint"], "https://provider.example/process") + + status, result = self._request_api( + "PUT", f"/services/{created['id']}", {"api_endpoint": ""}, token + ) + self.assertEqual(status, 400, result) + self.assertIn("api_endpoint", result.get("error", "")) + + def pricing_state(service_id): + db = self.module.get_db() + try: + row = db.execute( + "SELECT pricing_type,price,hourly_rate,delivery_time_days FROM services WHERE id=?", + [service_id], + ).fetchone() + return tuple(row) + finally: + db.close() + + fixed_state = pricing_state(created["id"]) + status, result = self._request_api( + "PUT", f"/services/{created['id']}", {"pricing_type": "hourly"}, token + ) + self.assertEqual(status, 400, result) + self.assertIn("hourly_rate", result.get("error", "")) + self.assertEqual(pricing_state(created["id"]), fixed_state) + + status, hourly_transition = self._request_api( + "PUT", + f"/services/{created['id']}", + {"pricing_type": "hourly", "hourly_rate": 50}, + token, + ) + self.assertEqual(status, 200, hourly_transition) + self.assertEqual(hourly_transition["pricing_type"], "hourly") + self.assertEqual(hourly_transition["hourly_rate"], 50) + self.assertIsNone(hourly_transition["price"]) + + hourly_state = pricing_state(created["id"]) + status, result = self._request_api( + "PUT", + f"/services/{created['id']}", + {"pricing_type": "fixed", "delivery_time_days": 3}, + token, + ) + self.assertEqual(status, 400, result) + self.assertIn("price", result.get("error", "").lower()) + self.assertEqual(pricing_state(created["id"]), hourly_state) + + status, fixed_transition = self._request_api( + "PUT", + f"/services/{created['id']}", + {"pricing_type": "fixed", "price": 125, "delivery_time_days": 3}, + token, + ) + self.assertEqual(status, 200, fixed_transition) + self.assertEqual(fixed_transition["pricing_type"], "fixed") + self.assertEqual(fixed_transition["price"], 125) + self.assertIsNone(fixed_transition["hourly_rate"]) + + valid_fixed_state = pricing_state(created["id"]) + status, result = self._request_api( + "PUT", f"/services/{created['id']}", {"price": 0}, token + ) + self.assertEqual(status, 400, result) + self.assertIn("price", result.get("error", "").lower()) + self.assertEqual(pricing_state(created["id"]), valid_fixed_state) + + status, custom_transition = self._request_api( + "PUT", f"/services/{created['id']}", {"pricing_type": "custom"}, token + ) + self.assertEqual(status, 200, custom_transition) + self.assertEqual(custom_transition["pricing_type"], "custom") + self.assertIsNone(custom_transition["price"]) + self.assertIsNone(custom_transition["hourly_rate"]) + + hourly = {**fixed, "pricing_type": "hourly", "price": None, "hourly_rate": 50} + status, created_hourly = self._request_api("POST", "/services", hourly, token) + self.assertEqual(status, 201, created_hourly) + self.assertIsNone(created_hourly["delivery_time_days"]) + self.assertIsNone(created_hourly["price"]) + + status, explicit_hourly = self._request_api( + "POST", "/services", {**hourly, "delivery_time_days": 2}, token + ) + self.assertEqual(status, 201, explicit_hourly) + self.assertEqual(explicit_hourly["delivery_time_days"], 2) + + custom = { + "title": "Scoped custom service", + "description": "Agree a custom scope before checkout.", + "category": "testing", + "pricing_type": "custom", + } + status, created_custom = self._request_api("POST", "/services", custom, token) + self.assertEqual(status, 201, created_custom) + self.assertIsNone(created_custom["price"]) + self.assertIsNone(created_custom["hourly_rate"]) + self.assertIsNone(created_custom["delivery_time_days"]) + + status, explicit_custom = self._request_api( + "POST", "/services", {**custom, "delivery_time_days": 5}, token + ) + self.assertEqual(status, 201, explicit_custom) + self.assertEqual(explicit_custom["delivery_time_days"], 5) + def test_existing_session_is_rejected_immediately_after_user_suspension(self): token = "tok-suspended-session" db = self.module.get_db() @@ -1699,6 +1909,106 @@ def test_high_intent_seo_pages_feed_starter_offer_funnel(self): missing[rel] = misses self.assertEqual(missing, {}) + def test_public_intent_ctas_do_not_emit_executive_conversion_events(self): + executive_event_call = re.compile( + r"(?:trackGHH|trackBlogCTA|trackEvent|trackRecommendedEvent|trackConfiguredKeyEvent)\(\s*['\"](?:generate_lead|qualify_lead|close_convert_lead|purchase)['\"]" + r"|gtag\(\s*['\"]event['\"]\s*,\s*['\"](?:generate_lead|qualify_lead|close_convert_lead|purchase)['\"]" + ) + executive_violations = {} + misrouted_post_task_ctas = {} + for page in sorted((REPO_ROOT / "frontend").rglob("*.html")): + text = page.read_text(encoding="utf-8", errors="ignore") + is_spa_root = page.name == "index.html" and page.parent == REPO_ROOT / "frontend" + inspected_text = "\n".join( + re.findall(r"<(?:a|button)\b[^>]*>", text, re.IGNORECASE) + ) if is_spa_root else text + matches = executive_event_call.findall(inspected_text) + if matches: + executive_violations[str(page.relative_to(REPO_ROOT))] = matches + bad_post_task_links = [] + for anchor in re.findall(r"]*>", text, re.IGNORECASE): + if "post_task_cta_click" not in anchor: + continue + href = re.search(r"\bhref\s*=\s*['\"]([^'\"]+)['\"]", anchor, re.IGNORECASE) + if href is None or "post-job" not in href.group(1): + bad_post_task_links.append(anchor[:240]) + if bad_post_task_links: + misrouted_post_task_ctas[str(page.relative_to(REPO_ROOT))] = bad_post_task_links + self.assertEqual(executive_violations, {}) + self.assertEqual(misrouted_post_task_ctas, {}) + + def test_faq_qualifies_checkout_and_privacy_sharing_claims(self): + faq = (REPO_ROOT / "frontend/faq.html").read_text(encoding="utf-8", errors="ignore") + self.assertNotIn("All payments are processed through Stripe", faq) + self.assertNotIn("never shared with third parties", faq) + self.assertIn("Where GoHireHumans checkout is configured, Stripe processes payments", faq) + self.assertIn("published listings and task content may be public", faq) + self.assertNotIn("service providers and marketplace participants as described in the Privacy Policy", faq) + self.assertIn("with Stripe for configured payment processing, with other users as needed for marketplace transactions, and with law enforcement when required", faq) + + def test_starter_offer_taxonomy_matches_pricing_and_draft_defaults(self): + pricing = (REPO_ROOT / "frontend/pricing.html").read_text(encoding="utf-8", errors="ignore") + starter = (REPO_ROOT / "frontend/starter-offers.html").read_text(encoding="utf-8", errors="ignore") + app = (REPO_ROOT / "frontend/index.html").read_text(encoding="utf-8", errors="ignore") + first_tasks = (REPO_ROOT / "frontend/post-a-small-task.html").read_text(encoding="utf-8", errors="ignore") + website_qa = (REPO_ROOT / "frontend/use-cases/website-qa-task.html").read_text(encoding="utf-8", errors="ignore") + lead_research = (REPO_ROOT / "frontend/use-cases/lead-research-microtask.html").read_text(encoding="utf-8", errors="ignore") + canonical_offers = { + "AI Output Verification": ("ai_review", "99"), + "Automation QA Sprint": ("automation_verification", "199"), + "Clay/GTM QA Sprint": ("clay_gtm_qa", "199"), + "Real-World Check": ("phone_fact_check", "79"), + } + for offer, (template, amount) in canonical_offers.items(): + self.assertIn(f"

{offer}

", pricing) + self.assertIn(f"

{offer}

", starter) + pricing_card = re.search( + rf"

{re.escape(offer)}

.*?
", + pricing, + re.DOTALL, + ) + if pricing_card is None: + self.fail(f"Missing pricing card for {offer}") + self.assertIn(f'href="/#/post-job?template={template}"', pricing_card.group(0), offer) + match = re.search( + rf"\b{re.escape(template)}:\s*\{{.*?budget_amount:\s*['\"](\d+)['\"]", + app, + re.DOTALL, + ) + if match is None: + self.fail(f"Missing draft template budget for {template}") + self.assertEqual(match.group(1), amount, template) + self.assertNotIn("

Website QA Sprint

", pricing) + self.assertNotIn("

Lead List Verification

", pricing) + self.assertIn("template=website_qa", website_qa) + self.assertNotIn("template=automation_verification", website_qa) + self.assertIn("template=lead_research", lead_research) + self.assertNotIn("template=clay_gtm_qa", lead_research) + self.assertIn("title: 'Verify AI agent or automation runs'", app) + self.assertIn("AI agent or automation outputs/runs", app) + self.assertIn("title: 'Human QA a Clay or GTM lead list'", app) + self.assertIn("Clay/GTM table or outbound list", app) + self.assertIn("Suggested: $49–$199", first_tasks) + self.assertIn("Suggested: $15–$79", first_tasks) + self.assertNotIn("Suggested: $49–$149", first_tasks) + self.assertNotIn("Suggested: $15–$75", first_tasks) + + def test_pricing_avoids_unsourced_competitor_rate_claims(self): + pricing = (REPO_ROOT / "frontend/pricing.html").read_text(encoding="utf-8", errors="ignore") + for name in ["Upwork", "Fiverr", "TaskRabbit"]: + self.assertNotIn(name, pricing) + self.assertIn("Other marketplaces", pricing) + self.assertIn("Varies; confirm current terms", pricing) + self.assertIn("1% + Stripe processing where checkout is configured", pricing) + structured_blocks = re.findall( + r'', pricing, re.DOTALL + ) + structured = [json.loads(block) for block in structured_blocks] + product = next(item for item in structured if item.get("@type") == "Product") + self.assertIn("where checkout is configured", product["description"].lower()) + service_fee = next(offer for offer in product["offers"] if offer["name"] == "Service Fee") + self.assertIn("where checkout is configured", service_fee["description"].lower()) + def test_first_orders_conversion_infrastructure_is_discoverable(self): required = { "frontend/index.html": [ @@ -1720,11 +2030,11 @@ def test_first_orders_conversion_infrastructure_is_discoverable(self): 'data-pricing-order="fee-first"', "Prefer a fixed starting point?", "pricing_proof_first_cta_click", - "lead_research", + "clay_gtm_qa", ], - "frontend/use-cases/hire-human-to-review-ai-output.html": ["AI-output review proof pack", "template=ai_review", "qualify_lead"], - "frontend/use-cases/website-qa-task.html": ["Website QA proof pack", "template=website_qa", "qualify_lead"], - "frontend/use-cases/lead-research-microtask.html": ["Lead research proof pack", "template=lead_qualification", "qualify_lead"], + "frontend/use-cases/hire-human-to-review-ai-output.html": ["AI-output review proof pack", "template=ai_review", "post_task_cta_click"], + "frontend/use-cases/website-qa-task.html": ["Website QA proof pack", "template=website_qa", "post_task_cta_click"], + "frontend/use-cases/lead-research-microtask.html": ["Lead research proof pack", "template=lead_research", "post_task_cta_click"], "frontend/examples/sample-deliverables.html": [ "Sample website QA report", "Sample AI-output review scorecard", @@ -2868,7 +3178,6 @@ def test_gig_economy_stats_routes_drive_by_readers_to_first_task_draft(self): "Turn the data into one clear task", "Draft your first task", "first_task_blog_cta_click", - "trackBlogCTA('qualify_lead'", "/#/post-job?template=website_test", ]: self.assertIn(snippet, text) diff --git a/backend/test_transaction_lifecycle_regressions.py b/backend/test_transaction_lifecycle_regressions.py index dbab5c1..a387cc5 100644 --- a/backend/test_transaction_lifecycle_regressions.py +++ b/backend/test_transaction_lifecycle_regressions.py @@ -1164,6 +1164,81 @@ def test_raw_json_money_numbers_preserve_lexical_form_and_fail_before_stripe(sel self.assertEqual(status, 400, result) self.payment_create.assert_not_called() + def test_service_pricing_transitions_remain_checkout_compatible(self): + with self.api.get_db() as db: + db.executemany( + """INSERT INTO services + (id,worker_id,title,description,category,pricing_type,price,hourly_rate, + delivery_time_days,status) + VALUES (?,?,?,?,?,?,?,?,?,'active')""", + [ + (10, 1, 'Fixed unchanged', 'Fixed scope', 'testing', 'fixed', 10, None, 3), + (11, 1, 'Fixed to hourly', 'Hourly scope', 'testing', 'fixed', 10, None, 3), + (12, 1, 'Hourly to fixed', 'Fixed scope', 'testing', 'hourly', None, 15, 4), + (13, 1, 'Fixed to custom', 'Custom scope', 'testing', 'fixed', 12, None, 5), + ], + ) + db.commit() + + status, rejected = self.request( + "PUT", "/services/10", token="tok-worker", payload={"pricing_type": "hourly"} + ) + self.assertEqual(status, 400, rejected) + status, fixed_order = self.request( + "POST", + "/services/10/order", + payload={"idempotency_key": "pricing-transition-fixed-0001"}, + ) + self.assertEqual(status, 201, fixed_order) + self.assertEqual(fixed_order["total_amount"], 10) + + status, hourly_listing = self.request( + "PUT", + "/services/11", + token="tok-worker", + payload={"pricing_type": "hourly", "hourly_rate": 20}, + ) + self.assertEqual(status, 200, hourly_listing) + self.assertIsNone(hourly_listing["price"]) + status, hourly_order = self.request( + "POST", + "/services/11/order", + payload={"hours": 2, "idempotency_key": "pricing-transition-hourly-001"}, + ) + self.assertEqual(status, 201, hourly_order) + self.assertEqual(hourly_order["total_amount"], 40) + + status, fixed_listing = self.request( + "PUT", + "/services/12", + token="tok-worker", + payload={"pricing_type": "fixed", "price": 30}, + ) + self.assertEqual(status, 200, fixed_listing) + self.assertIsNone(fixed_listing["hourly_rate"]) + status, transitioned_fixed_order = self.request( + "POST", + "/services/12/order", + payload={"idempotency_key": "pricing-transition-fixed-0002"}, + ) + self.assertEqual(status, 201, transitioned_fixed_order) + self.assertEqual(transitioned_fixed_order["total_amount"], 30) + self.assertIsNotNone(transitioned_fixed_order["deadline_at"]) + + status, custom_listing = self.request( + "PUT", "/services/13", token="tok-worker", payload={"pricing_type": "custom"} + ) + self.assertEqual(status, 200, custom_listing) + self.assertIsNone(custom_listing["price"]) + self.assertIsNone(custom_listing["hourly_rate"]) + status, custom_order = self.request( + "POST", + "/services/13/order", + payload={"amount": "22.00", "idempotency_key": "pricing-transition-custom-001"}, + ) + self.assertEqual(status, 201, custom_order) + self.assertEqual(custom_order["total_amount"], 22) + def test_hourly_service_product_rounds_from_exact_decimal_coefficients(self): with self.api.get_db() as db: db.execute( diff --git a/frontend/ai-assistant-human-checks.html b/frontend/ai-assistant-human-checks.html index 0be0754..d852195 100644 --- a/frontend/ai-assistant-human-checks.html +++ b/frontend/ai-assistant-human-checks.html @@ -53,7 +53,7 @@
AI assistant handoff guide

When an AI assistant should ask a human to check the work.

Use GoHireHumans when an AI-generated output affects a customer, payment, public claim, lead list, workflow handoff, or real-world decision and needs bounded human verification.

-

Start with a QA sprint See sample deliverables

+

Start with a QA sprint See sample deliverables

Good human-check triggers

diff --git a/frontend/blog/gig-economy-statistics-2026.html b/frontend/blog/gig-economy-statistics-2026.html index c60a844..13d09c8 100644 --- a/frontend/blog/gig-economy-statistics-2026.html +++ b/frontend/blog/gig-economy-statistics-2026.html @@ -1065,7 +1065,7 @@

Future Projections

Turn the data into one clear task

If you came here researching the gig economy, the next useful step is simple: post one bounded task — website QA, AI-output review, lead research, data cleanup, or a local check — and review the draft before publishing.

- Draft your first task + Draft your first task See AI QA services
diff --git a/frontend/faq.html b/frontend/faq.html index ec6c206..b30e42e 100644 --- a/frontend/faq.html +++ b/frontend/faq.html @@ -105,7 +105,7 @@ "name": "Is my information safe on GoHireHumans?", "acceptedAnswer": { "@type": "Answer", - "text": "GoHireHumans takes privacy and data security seriously. All payments are processed through Stripe, which is PCI-DSS compliant. All task details and communications are stored securely. Personal and financial information is never shared with third parties without consent. You control what information you share with professionals through the platform." + "text": "GoHireHumans takes privacy and data security seriously. Where GoHireHumans checkout is configured, Stripe processes payments and handles raw card details. Drafts remain private; published listings and task content may be public. GoHireHumans shares information with Stripe for configured payment processing, with other users as needed for marketplace transactions, and with law enforcement when required, as described in the Privacy Policy. Never put passwords, credentials, or sensitive customer data in public content." } }, { @@ -441,13 +441,14 @@

Frequently Asked Questions

Is my information safe?
-

GoHireHumans takes data security and privacy seriously. Key protections include:

+

GoHireHumans takes data security and privacy seriously. Key limits and practices include:

    -
  • Payments processed through Stripe (PCI-DSS compliant)
  • -
  • Encrypted data storage
  • -
  • No sharing of personal or financial data with third parties without consent
  • -
  • You control what task-specific details are shared with professionals
  • +
  • Where GoHireHumans checkout is configured, Stripe processes payments and handles raw card details.
  • +
  • Drafts remain private; published listings and task content may be public.
  • +
  • GoHireHumans shares information with Stripe for configured payment processing, with other users as needed for marketplace transactions, and with law enforcement when required, as described in the Privacy Policy.
  • +
  • Never put passwords, credentials, or sensitive customer data in public content.
+

Read the Privacy Policy →

diff --git a/frontend/index.html b/frontend/index.html index 9c782c6..2751b16 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -497,9 +497,11 @@

Common agent-delegated work

const hasExplicitCount = total !== undefined && total !== null; const count = Number(total || 0); if (hasExplicitCount && !count) return 'New listing'; - const r = Math.round(rating || 0); + const numericRating = Number(rating || 0); + const r = Math.round(numericRating); const starsHtml = [1,2,3,4,5].map(i => `${i <= r ? '★' : '☆'}`).join(''); - return `${starsHtml}${hasExplicitCount ? `(${count})` : ''}`; + const reviewLabel = hasExplicitCount ? `, ${count} review${count === 1 ? '' : 's'}` : ''; + return `${starsHtml}${hasExplicitCount ? `(${count})` : ''}`; } function formatPrice(service) { if (service.pricing_type === 'fixed') return `$${Number(service.price || 0).toFixed(0)}`; @@ -519,7 +521,33 @@

Common agent-delegated work

} function providerBadge(service) { if (service.provider_type === 'ai') return 'Agent service'; - return 'Human service'; + if (service.provider_type === 'human') return 'Human service'; + return ''; +} +function workerReviewFacts(service) { + const rawCount = service && service.worker_review_count; + const rawRating = service && service.worker_rating; + const count = Number(rawCount); + const rating = Number(rawRating); + return { + count, + rating, + countKnown: rawCount !== null && rawCount !== undefined && rawCount !== '' && Number.isFinite(count) && count >= 0, + ratingKnown: rawRating !== null && rawRating !== undefined && rawRating !== '' && Number.isFinite(rating) && rating >= 0 && rating <= 5 + }; +} +function workerReviewSummary(service) { + const facts = workerReviewFacts(service); + if (!facts.countKnown) return 'Review history unavailable'; + if (facts.count === 0) return stars(0, 0); + if (!facts.ratingKnown) return `${facts.count} review${facts.count === 1 ? '' : 's'}`; + return stars(facts.rating, facts.count); +} +function serviceDeliveryLabel(value) { + if (value === null || value === undefined || value === '') return ''; + const days = Number(value); + if (!Number.isInteger(days) || days < 1 || days > 365) return ''; + return `Delivery: ${days} day${days !== 1 ? 's' : ''}`; } function relativeDate(str) { if (!str) return ''; @@ -724,7 +752,7 @@

Common agent-delegated work

description: 'Please review the AI-generated output I provide for factual errors, unsupported claims, risky wording, missing context, and unclear recommendations.\n\nAcceptance criteria:\n1. Check every item I provide.\n2. Return an issue table with item, issue, severity, evidence/source when available, and suggested fix.\n3. Clearly flag uncertainty instead of guessing.\n\nDeliverable: concise QA report plus corrected lines or notes where useful.', category: 'expert_review', budget_type: 'fixed', - budget_amount: '49', + budget_amount: '99', required_skills: 'AI output review, fact checking, editing, source checking' }, automation_verification: { @@ -732,7 +760,7 @@

Common agent-delegated work

description: 'Please spot-check the AI agent or automation outputs/runs I provide and report whether they match the expected behavior.\n\nAcceptance criteria:\n1. Review the sample runs, logs, screenshots, or outputs I provide.\n2. Mark each run pass/fail/uncertain with evidence.\n3. Identify recurring failure modes and the top fixes to check next.\n\nDeliverable: run checklist, anomaly report, and confidence notes.', category: 'testing', budget_type: 'fixed', - budget_amount: '75', + budget_amount: '199', required_skills: 'automation QA, AI agent review, testing, concise reporting' }, clay_gtm_qa: { @@ -764,7 +792,7 @@

Common agent-delegated work

description: 'Please make the short call or verify the fact I specify using the public details I provide.\n\nAcceptance criteria:\n1. Do not share private data or credentials.\n2. Record when and what you checked.\n3. Flag uncertainty, voicemail, no-answer, or conflicting information.\n\nDeliverable: call/fact-check log, outcome, and any source notes.', category: 'phone_call', budget_type: 'fixed', - budget_amount: '35', + budget_amount: '79', required_skills: 'phone calls, fact verification, concise notes' }, lead_qualification: { @@ -1235,11 +1263,10 @@

Legal

try { const sRes = await api('/services?per_page=3'); liveServices = (sRes.services || []).map(s => ({ - title: s.title, worker: s.worker_name || 'Professional', + title: s.title, worker: s.worker_name || '', price: s.pricing_type === 'hourly' ? `$${s.hourly_rate}/hr` : s.price ? `$${s.price}` : 'Custom', - delivery: s.delivery_time_days ? `${s.delivery_time_days} days` : 'Flexible', - rating: Math.round(Number(s.avg_rating ?? s.rating ?? 0)), - reviews: Number(s.total_reviews ?? s.review_count ?? s.reviews ?? 0), + delivery: serviceDeliveryLabel(s.delivery_time_days), + reviewMarkup: workerReviewSummary(s), category: (s.category || s.category_name || '').replace(/_/g,' ').replace(/\b\w/g, c => c.toUpperCase()), id: s.id })); @@ -1254,11 +1281,11 @@

Legal

${esc(s.category)}
${esc(s.title)}
- ${I.clock} ${esc(s.delivery)} - By ${esc(s.worker)} + ${s.delivery ? `${I.clock} ${esc(s.delivery)}` : ''} + ${s.worker ? `By ${esc(s.worker)}` : ''}
-
${stars(s.rating, s.reviews)}
+
${s.reviewMarkup}
${esc(s.price)}
View details → @@ -1611,8 +1638,8 @@

Something went } function serviceCard(s) { - const rating = Number(s.avg_rating ?? s.rating ?? 0); - const reviews = Number(s.total_reviews ?? s.review_count ?? s.reviews ?? 0); + const deliveryLabel = serviceDeliveryLabel(s.delivery_time_days); + const deliveryMeta = deliveryLabel ? `
${I.clock} ${deliveryLabel}
` : ''; return `
@@ -1626,10 +1653,10 @@

${esc(s.title)}

${(s.worker_name||'?').slice(0,2).toUpperCase()}
${esc(s.worker_name || 'Worker')} -
${stars(rating, reviews)}
+
${workerReviewSummary(s)}
View details →
@@ -1675,6 +1702,9 @@

${esc(s.title)}

const canOrder = !!state.user; const orderBtn = ``; + const deliveryLabel = serviceDeliveryLabel(svc.delivery_time_days); + const deliveryMeta = deliveryLabel ? `
${I.clock} ${deliveryLabel}
` : ''; + const reviewFacts = workerReviewFacts(svc); document.getElementById('app').innerHTML = ` ${renderPublicNav('services')} @@ -1689,7 +1719,7 @@

${esc(svc.title)}

${(svc.worker_name||'?').slice(0,2).toUpperCase()}
${esc(svc.worker_name || 'Worker')}
-
${stars(svc.worker_avg_rating, svc.worker_total_reviews)}
+
${workerReviewSummary(svc)}
@@ -1732,8 +1762,8 @@

${esc(svc.title)}

${formatPrice(svc)}
${svc.pricing_type === 'hourly' ? `
per hour
` : ''}
-
${I.clock} Delivery: ${svc.delivery_time_days||'?'} day${(svc.delivery_time_days||0)!==1?'s':''}
- ${svc.avg_rating ? `
${Number(svc.avg_rating).toFixed(1)} rating (${svc.total_reviews} reviews)
` : ''} + ${deliveryMeta} + ${reviewFacts.countKnown && reviewFacts.ratingKnown && reviewFacts.count > 0 ? `
${reviewFacts.rating.toFixed(1)} rating (${reviewFacts.count} review${reviewFacts.count === 1 ? '' : 's'})
` : ''}
${orderBtn}

Payment status is tracked through the configured workflow after buyer review.

@@ -1747,7 +1777,7 @@

${esc(svc.title)}

-
${svc.provider_type === 'ai' ? 'About This AI Service' : 'About the Seller'}
+
${svc.provider_type === 'ai' ? 'About This AI Service' : svc.provider_type === 'human' ? 'About the Seller' : 'About the Provider'}
${svc.provider_type === 'ai' ? `
${svc.ai_model ? `
Model: ${esc(svc.ai_model)}
` : ''} @@ -2469,8 +2499,8 @@

My Services

${I.dollar} ${formatPrice(s)} - ${I.clock} ${s.delivery_time_days||'?'} days delivery - ★ ${s.avg_rating ? Number(s.avg_rating).toFixed(1) : 'No ratings'} (${s.total_reviews||0} reviews) + ${serviceDeliveryLabel(s.delivery_time_days) ? `${I.clock} ${serviceDeliveryLabel(s.delivery_time_days)}` : ''} + ${workerReviewSummary(s)}
@@ -2564,7 +2594,7 @@

${editId ? 'Edit Service' : 'Post a Service'}

- +
@@ -2585,7 +2615,7 @@

${editId ? 'Edit Service' : 'Post a Service'}

- @@ -2598,7 +2628,7 @@

${editId ? 'Edit Service' : 'Post a Service'}

- +
@@ -2613,6 +2643,7 @@

${editId ? 'Edit Service' : 'Post a Service'}

`, 'my-services'); + toggleAIFields(); }; loadAndRender(); } @@ -2627,10 +2658,13 @@

${editId ? 'Edit Service' : 'Post a Service'}

function toggleAIFields() { const isAI = document.getElementById('providerType')?.value === 'ai'; + const isAPIFulfilled = document.getElementById('fulfillmentType')?.value === 'api'; const fg = document.getElementById('fulfillmentGroup'); const ef = document.getElementById('aiExtraFields'); + const endpoint = document.getElementById('apiEndpoint'); if (fg) fg.style.display = isAI ? 'block' : 'none'; if (ef) ef.style.display = isAI ? 'block' : 'none'; + if (endpoint) endpoint.toggleAttribute('required', isAI && isAPIFulfilled); } async function handlePostService(e, editId) { diff --git a/frontend/post-a-small-task.html b/frontend/post-a-small-task.html index 55b6a79..92c4663 100644 --- a/frontend/post-a-small-task.html +++ b/frontend/post-a-small-task.html @@ -56,17 +56,17 @@
First task templates

Humans who verify what your AI produces.

Scope a bounded task for a human to review AI output, verify automation runs, clean data, research options, make calls, or qualify leads. Draft first; review before anything is published.

-

Scope my first QA task See sample proof report

+

Scope my first QA task See sample proof report

What happens next: you draft the task, review the scope, set a fixed budget, and decide when to publish. Where payment processing is configured, paid work follows platform payment/review steps before release.

Pick a starter task

AI output review / QA

Have a human check AI-written content, support replies, claims, citations, or recommendations for risk and accuracy.

Suggested: $29–$99

issue tableevidencesuggested fixes

Draft AI QA task

-

Automation / agent run verification

Ask a reviewer to spot-check AI-agent or automation runs and flag pass/fail, anomalies, and recurring failure modes.

Suggested: $49–$149

run checklistanomaly report

Draft automation QA task

+

Automation / agent run verification

Ask a reviewer to spot-check AI-agent or automation runs and flag pass/fail, anomalies, and recurring failure modes.

Suggested: $49–$199

run checklistanomaly report

Draft automation QA task

Data cleanup / enrichment

Clean, dedupe, enrich, or verify a small spreadsheet or record sample with judgment calls clearly flagged.

Suggested: $49–$199

change logambiguous rows

Draft data cleanup task

Research with human judgment

Request a sourced shortlist, tradeoff analysis, and recommendation against your decision criteria.

Suggested: $39–$149

sourcesrationale

Draft research task

-

Phone call or fact verification

Ask a human to confirm availability, pricing, contact details, local facts, or short call outcomes.

Suggested: $15–$75

call loguncertainty flagged

Draft phone/fact-check task

+

Phone call or fact verification

Ask a human to confirm availability, pricing, contact details, local facts, or short call outcomes.

Suggested: $15–$79

call loguncertainty flagged

Draft phone/fact-check task

Lead list qualification

Have a person review a lead list against clear criteria and return qualified/maybe/not-qualified with source notes.

Suggested: $0.30–$1/lead or fixed batch

scored listsource notes

Draft lead qualification task

diff --git a/frontend/pricing.html b/frontend/pricing.html index 126282c..f2afb48 100644 --- a/frontend/pricing.html +++ b/frontend/pricing.html @@ -8,7 +8,7 @@ Pricing — GoHireHumans | 1% GoHireHumans Fee + Stripe Processing, No Subscriptions - + @@ -37,7 +37,7 @@ "@context": "https://schema.org", "@type": "Product", "name": "GoHireHumans Marketplace", - "description": "Online marketplace for hiring human and agent service providers. 1% GoHireHumans fee plus Stripe processing per completed task.", + "description": "Online marketplace for hiring human and agent service providers. Where checkout is configured, employers pay a 1% GoHireHumans fee plus Stripe processing per completed task.", "url": "https://www.gohirehumans.com/pricing.html", "brand": {"@type": "Organization", "name": "GoHireHumans"}, "offers": [ @@ -52,7 +52,7 @@ { "@type": "Offer", "name": "Service Fee", - "description": "1% GoHireHumans fee plus Stripe processing applied per completed task. No monthly subscriptions.", + "description": "Where checkout is configured, a 1% GoHireHumans fee plus Stripe processing applies per completed task. No monthly subscriptions.", "priceSpecification": { "@type": "UnitPriceSpecification", "price": "1", @@ -262,15 +262,15 @@

The Fee

-

Compare Fees

-

One compact comparison. Other platforms can change their pricing; confirm current terms before making a decision.

- - +

Compare Fee Structures

+

Other marketplaces set their own fees and terms. Confirm current terms on the marketplace you are considering.

+
PlatformGoHireHumansUpworkFiverrTaskRabbit
+ - - - - + + + +
ItemGoHireHumansOther marketplaces
Platform fee1% + Stripe processingUp to 20%20%15%
Free to sign upYesYesYesVaries
Monthly subscriptionNoneOptionalOptionalNone
Physical or local tasksSupportedLimitedLimitedSupported
Platform fee1% + Stripe processing where checkout is configuredVaries; confirm current terms
Free to joinYesVaries by marketplace
Monthly subscriptionNone requiredVaries by marketplace
Physical or local tasksSupported task type; availability varies by locationVaries by marketplace and location
@@ -279,10 +279,10 @@

Compare Fees

Prefer a fixed starting point?

These proof-backed examples are optional. You can also browse the broader marketplace or describe any small, scoped task.

-

AI Output Verification

$99 — claims, sources, errors, and confidence notes.

View example →

-

Website QA Sprint

$199 — flow checks, screenshots, reproduction steps, and severity notes.

View example →

-

Lead List Verification

$199 — row checks, source accuracy, duplicates, and risk flags.

View example →

-

Real-World Check

$79 — a small phone, local, manual, or public-source verification.

View example →

+

AI Output Verification

$99 — claims, sources, errors, and confidence notes.

Start this draft →

+

Automation QA Sprint

$199 — agent-run or automation checks with screenshots, pass/fail evidence, and reproduction notes.

Start this draft →

+

Clay/GTM QA Sprint

$199 — lead-list source checks, duplicates, unsupported claims, and row-level risk flags.

Start this draft →

+

Real-World Check

$79 — a small phone, local, manual, or public-source verification.

Start this draft →

View starter offers diff --git a/frontend/tests/browser-regression.spec.js b/frontend/tests/browser-regression.spec.js index ff012fb..1cc8b65 100644 --- a/frontend/tests/browser-regression.spec.js +++ b/frontend/tests/browser-regression.spec.js @@ -369,12 +369,30 @@ test.describe('GoHireHumans public/browser regression suite', () => { test('homepage and pricing route high-intent visitors to proof-backed QA paths', async ({ page }) => { await setupDeterministicLocalPage(page); await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => saveSession('pricing-draft-test-token', { + id: 501, + name: 'Pricing Draft Tester', + email: 'pricing-draft@example.test' + })); await expect(page.locator('body')).toContainText('What do you need help with?'); await expect(page.locator('.lp-start-card[href="/starter-offers.html"]')).toContainText('Start with QA'); await page.goto('/pricing.html', { waitUntil: 'domcontentloaded' }); await expect(page.locator('body')).toContainText('Prefer a fixed starting point?'); - await expect(page.locator('a[href="/use-cases/hire-human-to-review-ai-output.html"]').first()).toBeVisible(); - await expect(page.locator('a[href="/use-cases/lead-research-microtask.html"]').first()).toBeVisible(); + const offers = [ + { heading: 'AI Output Verification', template: 'ai_review', title: 'Have a human QA AI-generated output', budget: '99' }, + { heading: 'Automation QA Sprint', template: 'automation_verification', title: 'Verify AI agent or automation runs', budget: '199' }, + { heading: 'Clay/GTM QA Sprint', template: 'clay_gtm_qa', title: 'Human QA a Clay or GTM lead list', budget: '199' }, + { heading: 'Real-World Check', template: 'phone_fact_check', title: 'Make phone calls or verify a fact', budget: '79' } + ]; + for (const offer of offers) { + await page.goto('/pricing.html', { waitUntil: 'domcontentloaded' }); + const card = page.locator('.feature-item').filter({ has: page.getByRole('heading', { name: offer.heading, exact: true }) }); + await card.getByRole('link', { name: 'Start this draft' }).click(); + await expect(page).toHaveURL(new RegExp(`\\?template=${offer.template}(?:#.*)?$`)); + await expect(page.locator('input[name="title"]')).toHaveValue(offer.title); + await expect(page.locator('input[name="budget_amount"]')).toHaveValue(offer.budget); + } + await page.goto('/pricing.html', { waitUntil: 'domcontentloaded' }); await expect(page.locator('body')).toContainText('View starter offers'); await page.goto('/starter-offers.html', { waitUntil: 'domcontentloaded' }); await expect(page.locator('body')).toContainText('Four proof-backed starter offers'); @@ -992,13 +1010,56 @@ test.describe('GoHireHumans public/browser regression suite', () => { test.skip(!isMobile, 'mobile-only smoke'); await setupDeterministicLocalPage(page); await page.goto('/pricing.html'); - await expect(page.locator('body')).toContainText('Compare Fees'); + await expect(page.locator('body')).toContainText('Compare Fee Structures'); await page.goto('/#/services', { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); await expect(page.locator('#services-result-count')).toBeVisible(); await expect(page.locator('[data-filter-toggle]')).toHaveText('Filters'); }); + test('homepage service previews preserve unknown, zero, and canonical worker facts', async ({ page }) => { + await setupDeterministicLocalPage(page); + await page.route('https://gohirehumans-production.up.railway.app/services?**', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ services: [ + { + id: 31, + title: 'Unknown review history', + worker_name: null, + category: 'testing', + pricing_type: 'fixed', + price: 99, + delivery_time_days: null, + worker_rating: null, + worker_review_count: null, + }, + { + id: 32, + title: 'Legacy zero-day listing', + worker_name: 'Fast verifier', + category: 'testing', + pricing_type: 'fixed', + price: 79, + delivery_time_days: 0, + worker_rating: 0, + worker_review_count: 0, + }, + ], total: 2, page: 1, per_page: 3 }), + })); + + await page.goto('/', { waitUntil: 'domcontentloaded' }); + const cards = page.locator('.lp-feed-card'); + await expect(cards).toHaveCount(2); + await expect(cards.nth(0)).toContainText('Review history unavailable'); + await expect(cards.nth(0)).not.toContainText('New listing'); + await expect(cards.nth(0)).not.toContainText('Flexible'); + await expect(cards.nth(0)).not.toContainText('By Professional'); + await expect(cards.nth(1)).toContainText('New listing'); + await expect(cards.nth(1)).not.toContainText('Same-day delivery'); + await expect(cards.nth(1)).not.toContainText('0 days'); + }); + test('simplified homepage presents one broad buyer path without repeated modules', async ({ page }) => { await setupDeterministicLocalPage(page); await page.goto('/', { waitUntil: 'domcontentloaded' }); @@ -1045,9 +1106,10 @@ test.describe('GoHireHumans public/browser regression suite', () => { status: 200, contentType: 'application/json', body: JSON.stringify({ services: [ - { id: 'svc-new', title: 'New service', description: 'No reviews yet.', pricing_type: 'fixed', price: 50, avg_rating: 0, total_reviews: 0, provider_type: 'human', worker_name: 'New provider', delivery_time_days: 2 }, - { id: 'svc-reviewed', title: 'Reviewed service', description: 'Has verified review history.', pricing_type: 'fixed', price: 75, avg_rating: 4.8, total_reviews: 8, provider_type: 'human', worker_name: 'Reviewed provider', delivery_time_days: 3 } - ], total: 2 }) + { id: 'svc-new', title: 'New service', description: 'No reviews yet.', pricing_type: 'fixed', price: 50, worker_rating: 0, worker_review_count: 0, provider_type: 'human', worker_name: 'New provider', delivery_time_days: 2 }, + { id: 'svc-reviewed', title: 'Reviewed service', description: 'Has verified review history.', pricing_type: 'fixed', price: 75, worker_rating: 4.8, worker_review_count: 8, provider_type: 'human', worker_name: 'Reviewed provider', delivery_time_days: 3 }, + { id: 'svc-unknown', title: 'Unknown review history', description: 'Review facts unavailable.', pricing_type: 'fixed', price: 65, worker_rating: null, worker_review_count: null, provider_type: 'human', worker_name: 'Unverified provider', delivery_time_days: 4 } + ], total: 3 }) })); await page.goto('/#/services', { waitUntil: 'domcontentloaded' }); await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); @@ -1067,12 +1129,193 @@ test.describe('GoHireHumans public/browser regression suite', () => { const reviewedCard = page.locator('.svc-card').nth(1); await expect(reviewedCard.locator('.stars-row')).toBeVisible(); await expect(reviewedCard).not.toContainText('New listing'); + const unknownCard = page.locator('.svc-card').nth(2); + await expect(unknownCard.locator('.stars-row')).toHaveCount(0); + await expect(unknownCard).toContainText('Review history unavailable'); + await expect(unknownCard).not.toContainText('New listing'); const sellerCta = page.locator('[data-seller-cta]').first(); await expect(sellerCta).toBeVisible(); const sellerTop = await sellerCta.evaluate(el => el.getBoundingClientRect().top); expect(sellerTop).toBeGreaterThan(top); }); + test('service detail uses canonical provider, review, and delivery facts', async ({ page }) => { + await setupDeterministicLocalPage(page); + await page.route('https://gohirehumans-production.up.railway.app/services/91', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 91, + title: 'Evidence review', + description: 'Review one bounded artifact.', + category: 'expert_review', + pricing_type: 'fixed', + price: 99, + worker_id: 14, + worker_name: 'Early provider', + provider_type: null, + worker_rating: null, + worker_review_count: null, + delivery_time_days: null + }) + })); + await page.route('https://gohirehumans-production.up.railway.app/users/14/reviews', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ reviews: [] }) + })); + await page.goto('/#/services/91', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Evidence review' })).toBeVisible(); + await expect(page.locator('.badge-human, .badge-ai')).toHaveCount(0); + await expect(page.locator('.stars-row')).toHaveCount(0); + await expect(page.locator('.svc-worker-row')).toContainText('Review history unavailable'); + await expect(page.locator('.svc-worker-row')).not.toContainText('New listing'); + await expect(page.locator('.svc-order-meta')).not.toContainText('Delivery:'); + await expect(page.getByText('About the Provider', { exact: true })).toBeVisible(); + await expect(page.getByText('About the Seller', { exact: true })).toHaveCount(0); + await expect(page.locator('body')).not.toContainText('? days'); + + await page.route('https://gohirehumans-production.up.railway.app/services/92', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 92, + title: 'Legacy zero-day verification', + description: 'Legacy invalid delivery metadata.', + category: 'expert_review', + pricing_type: 'fixed', + price: 125, + worker_id: 15, + worker_name: 'Known provider', + provider_type: 'human', + worker_rating: 5, + worker_review_count: 1, + delivery_time_days: 0 + }) + })); + await page.route('https://gohirehumans-production.up.railway.app/users/15/reviews', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ reviews: [] }) + })); + await page.goto('/#/services/92', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Legacy zero-day verification' })).toBeVisible(); + await expect(page.locator('.badge-human')).toContainText('Human service'); + await expect(page.locator('.stars-row')).toHaveAttribute('aria-label', '5.0 out of 5 stars, 1 review'); + await expect(page.locator('.svc-order-meta')).not.toContainText('Delivery:'); + await expect(page.getByText('About the Seller', { exact: true })).toBeVisible(); + await expect(page.locator('body')).not.toContainText('Same-day delivery'); + await expect(page.locator('body')).not.toContainText('0 days'); + + await page.route('https://gohirehumans-production.up.railway.app/services/93', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 93, + title: 'Legacy unknown provider', + description: 'Legacy provider metadata must fail closed.', + category: 'expert_review', + pricing_type: 'fixed', + price: 80, + worker_id: 16, + worker_name: 'Legacy provider', + provider_type: 'unknown', + worker_rating: 0, + worker_review_count: 0, + delivery_time_days: 2 + }) + })); + await page.route('https://gohirehumans-production.up.railway.app/users/16/reviews', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ reviews: [] }) + })); + await page.goto('/#/services/93', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Legacy unknown provider' })).toBeVisible(); + await expect(page.locator('.badge-human, .badge-ai')).toHaveCount(0); + await expect(page.getByText('About the Provider', { exact: true })).toBeVisible(); + }); + + test('My Services preserves unknown versus zero review facts and omits invalid delivery', async ({ page }) => { + await page.addInitScript(() => { + sessionStorage.setItem('ghh_token', 'browser-my-services-token'); + localStorage.setItem('ghh_user', JSON.stringify({ + id: 42, + name: 'Service Owner', + email: 'owner@example.test' + })); + }); + await page.route('https://accounts.google.com/**', route => route.fulfill({ status: 204, body: '' })); + await page.route('https://gohirehumans-production.up.railway.app/**', route => { + const url = new URL(route.request().url()); + if (url.pathname === '/services') { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ services: [ + { + id: 941, + worker_id: 42, + title: 'Unknown review history', + category: 'testing', + pricing_type: 'fixed', + price: 99, + delivery_time_days: 0, + worker_rating: null, + worker_review_count: null, + status: 'active' + }, + { + id: 942, + worker_id: 42, + title: 'Known new listing', + category: 'testing', + pricing_type: 'fixed', + price: 79, + delivery_time_days: 1, + worker_rating: null, + worker_review_count: 0, + status: 'active' + }, + { + id: 943, + worker_id: 99, + title: 'Another owner listing', + category: 'testing', + pricing_type: 'fixed', + price: 50, + delivery_time_days: 2, + worker_rating: 5, + worker_review_count: 2, + status: 'active' + } + ] }) + }); + } + return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }); + }); + + await page.goto('/#/my-services'); + await expect(page.getByRole('heading', { name: 'My Services' })).toBeVisible(); + const unknown = page.locator('.task-card').filter({ hasText: 'Unknown review history' }); + await expect(unknown).toContainText('Review history unavailable'); + await expect(unknown).not.toContainText('New listing'); + await expect(unknown).not.toContainText('delivery'); + const knownZero = page.locator('.task-card').filter({ hasText: 'Known new listing' }); + await expect(knownZero).toContainText('New listing'); + await expect(knownZero).toContainText('Delivery: 1 day'); + await expect(page.getByText('Another owner listing')).toHaveCount(0); + + await page.evaluate(() => renderPostService()); + await expect(page.getByRole('heading', { name: 'Post a Service' })).toBeVisible(); + await page.locator('.ai-service-toggle summary').click(); + await page.locator('#providerType').selectOption('ai'); + await page.locator('#fulfillmentType').selectOption('api'); + await expect(page.locator('#apiEndpoint')).toHaveAttribute('required', ''); + await page.locator('#fulfillmentType').selectOption('manual'); + await expect(page.locator('#apiEndpoint')).not.toHaveAttribute('required', ''); + }); + test('empty jobs route has one truthful state and no contradictory inventory claims', async ({ page }) => { await setupDeterministicLocalPage(page); await page.route('https://gohirehumans-production.up.railway.app/jobs**', route => route.fulfill({ diff --git a/frontend/use-cases/hire-human-to-review-ai-output.html b/frontend/use-cases/hire-human-to-review-ai-output.html index 16eaffb..e950010 100644 --- a/frontend/use-cases/hire-human-to-review-ai-output.html +++ b/frontend/use-cases/hire-human-to-review-ai-output.html @@ -28,7 +28,7 @@ if (event.target.closest('.lp-mobile-link')) toggleMobileMenu(false); }); } -
AI output review

Hire a human to review AI output before it reaches customers.

Use GoHireHumans to turn risky AI-generated content into a bounded review task with clear inputs, payout, and acceptance criteria.

Post this task Browse helpers

Good tasks to post

Fact-check an AI article

Have a reviewer label unsupported claims, broken sources, and facts that need correction.

Review support replies

Ask a human to check AI-drafted customer replies for tone, policy fit, and escalation risk.

Score AI research output

Get a human to compare AI summaries against source links and return issues by severity.

AI-output review proof pack

Ask for a claim/risk table with source links, unsupported-claim flags, safer rewrite suggestions, and a final ready / revise / do-not-publish recommendation.

Start from this proof-pack task See proof-pack format

What to include in the listing

Inputs

Add the exact URL, document, spreadsheet, prompt output, or source list workers should inspect.

Deliverable

Ask for a concise report with screenshots, links, notes, priority labels, or spreadsheet columns.

Acceptance criteria

State what complete work looks like, what not to do, and whether private data or off-platform contact is prohibited.

Related GoHireHumans pages

AI output reviewwebsite QAlead researchhuman verification