Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions backend/license/email/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
# It names the app's labels as they read: Allow, Sources, Jobs, Run, Security questionnaire.
_FIRST_JOB_STEP = (
"5. Run your first job. When SurfSense asks to reach its license server, choose Allow, "
"and it downloads the job.\n"
"and it downloads your jobs.\n"
" Then upload a questionnaire (.xlsx) and your policies to Sources, click Jobs in the "
"left sidebar, and press Run beside Security questionnaire.\n"
)
Expand Down Expand Up @@ -149,7 +149,8 @@ def _install_steps(
),
"trial": (
"Your {days}-day SurfSense Business trial license is attached. It "
"unlocks the job plugins, starting with the security questionnaire.\n\n"
"unlocks the job plugins: the security questionnaire and the contract "
"redline.\n\n"
"When it expires, SurfSense keeps working and everything you made stays "
"readable; only new job runs stop."
),
Expand All @@ -168,10 +169,10 @@ def _install_steps(
"The attached file replaces the old one: import it in Settings -> "
"License on each computer you use SurfSense on, or choose Check for "
"renewal on the Jobs screen, which fetches it.\n\n"
"Business unlocks SurfSense's job plugins, starting with the security "
"questionnaire. The scraper plugins your Individual license included "
"have retired; reading web pages, YouTube and web search are now built "
"into SurfSense, free.\n\n"
"Business unlocks SurfSense's job plugins: the security questionnaire "
"and the contract redline. The scraper plugins your Individual license "
"included have retired; reading web pages, YouTube and web search are "
"now built into SurfSense, free.\n\n"
"{term}"
),
}
Expand Down
4 changes: 3 additions & 1 deletion backend/license/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
from license.models import META_JOBS

# Every job is in Business (ADR 0080, open question a); a new one is added here
# and the setup script is run again.
# and the setup script is run again. A license keeps the list it was issued with,
# so a job added once Business is on sale reaches no license sold before it.
JOBS: dict[str, str] = {
"JOB_QUESTIONNAIRE": "Security questionnaire",
"JOB_CONTRACT_REDLINE": "Contract redline",
}


Expand Down
3 changes: 2 additions & 1 deletion backend/license/tests/test_business_purchase.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from fastapi import FastAPI

from license import router as license_router
from license.jobs import jobs_metadata
from license.tests.billing_harness import (
BUSINESS_PRICE,
PORTAL,
Expand Down Expand Up @@ -99,7 +100,7 @@ async def test_a_business_checkout_creates_one_license_with_the_people_and_the_j
"plan": "business",
"email": "partner@acme.example",
"firm": "Acme Accounting LLP",
"jobs": "JOB_QUESTIONNAIRE",
"jobs": jobs_metadata(),
"stripeSubscriptionId": "sub_firm",
"checkoutSessionId": "cs_business",
"stripeCustomerId": "cus_firm",
Expand Down
3 changes: 2 additions & 1 deletion backend/license/tests/test_business_trial.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pytest

from license.email import deliver
from license.jobs import jobs_metadata
from license.settings import config
from license.tests.billing_harness import PORTAL, SentMail
from license.tests.test_license_routes import FakeKeygen, keygen_store, mailer, post
Expand Down Expand Up @@ -66,7 +67,7 @@ async def test_with_the_switch_on_the_trial_is_businesss(
assert record["policy"] == "policy-business-trial"
# Still a trial to the app and to the cohort report; the jobs say what it unlocks.
assert record["attributes"]["metadata"]["plan"] == "trial"
assert record["attributes"]["metadata"]["jobs"] == "JOB_QUESTIONNAIRE"
assert record["attributes"]["metadata"]["jobs"] == jobs_metadata()
(mail,) = sent
assert mail.subject == "Your SurfSense Business 14-day trial license"
assert "security questionnaire" in mail.text_body
Expand Down
6 changes: 3 additions & 3 deletions backend/license/tests/test_convert_individual_licenses.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ async def test_apply_makes_the_license_business_solo_and_mails_one_notice(
"stripeCustomerId": "cus_lic-solo",
"trialKey": "solo@example.com",
"note": "kept by support",
"jobs": "JOB_QUESTIONNAIRE",
"jobs": jobs.jobs_metadata(),
# Found through its checkout, and written, as every Business license carries it.
"stripeSubscriptionId": "sub_lic-solo",
"convertedFrom": "individual",
Expand Down Expand Up @@ -1590,7 +1590,7 @@ async def test_a_refund_of_its_first_year_suspends_a_converted_license(
assert held(env)["attributes"]["status"] == "SUSPENDED"


async def test_a_converted_license_gets_its_job_and_resends_as_business(
async def test_a_converted_license_gets_every_job_and_resends_as_business(
env: Billing,
) -> None:
individual_buyer(env)
Expand All @@ -1599,7 +1599,7 @@ async def test_a_converted_license_gets_its_job_and_resends_as_business(
verdict = await license_verdict("KEY-lic-solo")
(resent,) = await licenses_for_email("solo@example.com")

assert verdict == LicenseVerdict("VALID", ("JOB_QUESTIONNAIRE",))
assert verdict == LicenseVerdict("VALID", tuple(jobs.JOBS))
assert BusinessCopy.of(resent) == BusinessCopy(people=1, paid=True)


Expand Down
18 changes: 18 additions & 0 deletions backend/license/tests/test_job_pack_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from license import token_bucket
from license.job_packs import catalog
from license.jobs import jobs_metadata
from license.portal import license_portal
from license.settings import config
from license.tests.github_fake import FakeGitHub
Expand All @@ -28,6 +29,7 @@
FIRM = "lic-firm"
KEY = f"KEY-{FIRM}"
DDQ = ("sample-ddq", "workbook-rows/1", "JOB_DDQ")
REDLINE = ("contract-redline", "word-clauses/1", "JOB_CONTRACT_REDLINE")
UNAVAILABLE = "Jobs cannot be downloaded right now. Try again later."


Expand Down Expand Up @@ -91,6 +93,22 @@ async def test_a_business_trial_gets_the_pack(
assert reply.status_code == 200


async def test_a_business_license_gets_the_contract_redline_from_its_own_pack(
pack_portal: PackPortal, trusted_pack_key
) -> None:
"""Business sells it beside the questionnaire, and every Business license lists both."""
licensed(pack_portal, jobs=jobs_metadata())
write_pack(pack_portal.packs)
write_pack(pack_portal.packs, pack_id="surfsense-contract-redline", jobs=(REDLINE,))

reply = await pack_portal.ask(
KEY, "JOB_CONTRACT_REDLINE", runners=("workbook-rows/1", "word-clauses/1")
)

assert reply.status_code == 200
assert reply.headers["x-job-pack-id"] == "surfsense-contract-redline"


async def test_jobs_written_with_spaces_still_match(
pack_portal: PackPortal, trusted_pack_key
) -> None:
Expand Down
25 changes: 22 additions & 3 deletions backend/license/tests/test_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pytest

from license.email.message import ACTIVATION_GUIDE_URL, build_license_email
from license.jobs import JOBS

pytestmark = pytest.mark.unit

Expand Down Expand Up @@ -199,6 +200,23 @@ def test_the_business_trial_says_what_stops_and_names_no_portal(monkeypatch):
assert "https://portal.test/p" not in message.text_body


@pytest.mark.parametrize("kind", ["trial", "conversion"])
def test_the_business_trial_and_the_conversion_notice_name_every_job(kind):
"""Each says what the license unlocks, and its `jobs` lists every job Business sells."""
term = {"renews_on": date(2027, 10, 26)} if kind == "conversion" else {}
body = build_license_email(
kind,
to="a@b.test",
certificates=("CERT",),
business=business(people=1, **term),
).text_body

# The intro, not the first-job step, which names only the questionnaire.
intro = body.split("1. Install SurfSense", 1)[0].lower()
for name in JOBS.values():
assert name.lower() in intro


def test_only_a_paid_business_license_resent_names_the_portal(monkeypatch):
"""Resend takes a trial's copy when the address holds no paid license, and a trial
has no subscription to change or cancel."""
Expand Down Expand Up @@ -235,7 +253,8 @@ def test_a_business_purchase_trial_or_conversion_ends_with_the_first_job(kind):

for words in (
"5. Run your first job.",
"choose Allow",
# Allowing downloads every job the license lists, not only the one run here.
"choose Allow, and it downloads your jobs.",
"click Jobs in the left sidebar",
"press Run beside Security questionnaire",
):
Expand Down Expand Up @@ -326,8 +345,8 @@ def test_the_conversion_notice_says_what_changed_and_what_stays(monkeypatch):
"The attached file replaces the old one: import it in Settings -> License "
"on each computer you use SurfSense on, or choose Check for renewal on the "
"Jobs screen, which fetches it.",
"Business unlocks SurfSense's job plugins, starting with the security "
"questionnaire.",
"Business unlocks SurfSense's job plugins: the security questionnaire and "
"the contract redline.",
"The scraper plugins your Individual license included have retired; reading "
"web pages, YouTube and web search are now built into SurfSense, free.",
# Its buyer may never have read that it renews: the notice names the day.
Expand Down
21 changes: 12 additions & 9 deletions backend/license/tests/test_setup_business_policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import pytest

from license import keygen
from license.jobs import JOBS
from license.scripts.setup_business_policies import SetupError, setup
from license.settings import config

Expand Down Expand Up @@ -150,17 +151,20 @@ def business_policies(account: FakeAccount) -> dict[str, dict]:
}


async def test_a_first_run_creates_both_policies_and_the_job_on_each(
async def test_a_first_run_creates_both_policies_and_every_job_on_each(
account: FakeAccount,
) -> None:
lines = await setup()

ours = business_policies(account)
assert set(ours) == {"business", "business-trial"}
(job,) = account.entitlements.values()
assert job["attributes"]["code"] == "JOB_QUESTIONNAIRE"
created = {
entitlement["attributes"]["code"]: entitlement["attributes"]["name"]
for entitlement in account.entitlements.values()
}
assert created == JOBS
for policy in ours.values():
assert account.attached[policy["id"]] == {next(iter(account.entitlements))}
assert account.attached[policy["id"]] == set(account.entitlements)
assert lines == [
f"KEYGEN_POLICY_BUSINESS={ours['business']['id']}",
f"KEYGEN_POLICY_BUSINESS_TRIAL={ours['business-trial']['id']}",
Expand Down Expand Up @@ -201,17 +205,16 @@ async def test_a_second_run_creates_nothing(account: FakeAccount) -> None:
async def test_a_new_job_is_created_and_attached_on_a_rerun(
account: FakeAccount, monkeypatch: pytest.MonkeyPatch
) -> None:
from license import jobs

await setup()
monkeypatch.setitem(jobs.JOBS, "JOB_RFP", "RFP responses")
sold = set(JOBS)
monkeypatch.setitem(JOBS, "JOB_RFP", "RFP responses")

await setup()

codes = {e["attributes"]["code"] for e in account.entitlements.values()}
assert codes == {"JOB_QUESTIONNAIRE", "JOB_RFP"}
assert codes == sold | {"JOB_RFP"}
for policy in business_policies(account).values():
assert len(account.attached[policy["id"]]) == 2
assert len(account.attached[policy["id"]]) == len(sold) + 1


async def test_the_live_policies_are_only_read(account: FakeAccount) -> None:
Expand Down
Loading
Loading