455 lines
17 KiB
JavaScript
455 lines
17 KiB
JavaScript
// Scoped P1: opportunity resource country/hierarchy, persistence and map recovery.
|
||
const crypto = require('node:crypto');
|
||
const {
|
||
start,
|
||
BASE,
|
||
TARGET,
|
||
DIR,
|
||
path,
|
||
fs: _fs,
|
||
assert,
|
||
sanitized,
|
||
} = require('./portal-functional-qa-lib.cjs');
|
||
async function browserRequest(page, method, url, options={}) {
|
||
assert.equal(new URL(url).origin,BASE);
|
||
const result=await page.evaluate(async ({method,url,options})=>{
|
||
const r=await fetch(url,{method,headers:{...options.headers,...(method==='POST'?{'Content-Type':'application/json'}:{})},...(method==='POST'?{body:JSON.stringify(options.data)}:{})});
|
||
return {status:r.status,headers:Object.fromEntries(r.headers.entries()),text:await r.text()};
|
||
},{method,url,options});
|
||
return {status:()=>result.status,headers:()=>result.headers,text:async()=>result.text,json:async()=>JSON.parse(result.text)};
|
||
}
|
||
void (async () => {
|
||
const screenshotOnly = process.argv.includes('--map-screenshot');
|
||
const q = await start(
|
||
screenshotOnly
|
||
? 'functional-coedit-map-chromium-20261011'
|
||
: 'functional-coedit-resources-chromium-20261011',
|
||
);
|
||
let page, ctx;
|
||
const route = '/minhas-oportunidades/15/resources';
|
||
const names = {
|
||
mz: 'QA contexto Cabo Delgado Ancuabe — 11/10/2026 coedição QA22',
|
||
pt: 'QA localização incompleta Portugal — 11/10/2026 coedição QA22',
|
||
};
|
||
async function requestHeaders() {
|
||
assert(ctx.qaConfirmed);
|
||
const cookie = (await ctx.cookies(BASE)).find(
|
||
(c) => c.name === 'XSRF-TOKEN',
|
||
);
|
||
assert(cookie, 'QA CSRF cookie required');
|
||
return {
|
||
Accept: 'application/json',
|
||
'X-XSRF-TOKEN': decodeURIComponent(cookie.value),
|
||
};
|
||
}
|
||
async function props() {
|
||
const r = await browserRequest(page, 'GET', BASE + route, {
|
||
headers: { Accept: 'text/html' },
|
||
});
|
||
assert.equal(r.headers()['x-investmoz-qa-target'], TARGET);
|
||
assert.equal(r.status(), 200);
|
||
const html = await r.text(),
|
||
encoded = html.match(/data-page="([^"]+)"/);
|
||
let data;
|
||
if (encoded && encoded[1].startsWith('{'))
|
||
data = JSON.parse(
|
||
encoded[1]
|
||
.replace(/"/g, '"')
|
||
.replace(/&/g, '&')
|
||
.replace(/'/g, "'"),
|
||
);
|
||
else {
|
||
const script = html.match(
|
||
/<script[^>]*type="application\/json"[^>]*>([\s\S]*?)<\/script>/,
|
||
);
|
||
assert(script, 'Inertia bootstrap required');
|
||
data = JSON.parse(script[1]);
|
||
}
|
||
return data.props;
|
||
}
|
||
async function choose(label, pattern) {
|
||
const s = page.getByRole('combobox', { name: label, exact: true });
|
||
const option = s
|
||
.locator('option')
|
||
.filter({ hasText: new RegExp(pattern.source, 'i') })
|
||
.first();
|
||
await option.waitFor({ state: 'attached' });
|
||
const selected = await option.evaluate((o) => ({
|
||
id: o.value,
|
||
name: o.textContent,
|
||
}));
|
||
await s.selectOption(selected.id);
|
||
return selected;
|
||
}
|
||
async function savedLocation(title) {
|
||
const p = await props();
|
||
return p.resources.locations.find(
|
||
(r) => r.payload.title === title && !r.deleted_at,
|
||
);
|
||
}
|
||
async function edit(title) {
|
||
await q.open(page, route);
|
||
await page
|
||
.getByRole('article')
|
||
.filter({ hasText: title })
|
||
.getByRole('button', {
|
||
name: 'Ver / editar / restaurar',
|
||
exact: true,
|
||
})
|
||
.click();
|
||
await page
|
||
.getByRole('heading', {
|
||
name: 'Editar recurso / nova versão',
|
||
exact: true,
|
||
})
|
||
.waitFor();
|
||
}
|
||
async function map(id) {
|
||
const r = await q.json(
|
||
page,
|
||
'/minhas-oportunidades/territory-geometries?territory_id=' + id,
|
||
);
|
||
assert.equal(r.status, 200);
|
||
const box = page.getByRole('region', {
|
||
name: 'Cobertura cartográfica da seleção',
|
||
exact: true,
|
||
});
|
||
await box
|
||
.getByText(
|
||
/Limite administrativo disponível|Sem limite administrativo acessível/,
|
||
)
|
||
.waitFor();
|
||
if (r.data.areas.length)
|
||
await box
|
||
.locator('.leaflet-overlay-pane svg path')
|
||
.first()
|
||
.waitFor({ state: 'attached' });
|
||
const polygons = await box
|
||
.locator('.leaflet-overlay-pane svg path')
|
||
.count();
|
||
assert(r.data.areas.length === 0 || polygons > 0);
|
||
return {
|
||
selected_geometry_available: r.data.selected_geometry_available,
|
||
context_geometry_count: r.data.context_geometry_count,
|
||
rendered_polygon_paths: polygons,
|
||
external_tiles: 'blocked/not tested',
|
||
};
|
||
}
|
||
async function save(title) {
|
||
await page.getByLabel('Título', { exact: true }).fill(title);
|
||
await page
|
||
.getByLabel('Motivo (20–1000 caracteres)', { exact: true })
|
||
.fill(
|
||
'Ensaio territorial sintético QA, contexto administrativo sem atribuição de direitos.',
|
||
);
|
||
const response = page.waitForResponse(
|
||
(r) =>
|
||
r.request().method() === 'POST' &&
|
||
new URL(r.url()).pathname.includes('/resources/locations'),
|
||
);
|
||
await page
|
||
.getByRole('button', { name: 'Guardar recurso', exact: true })
|
||
.click();
|
||
const r = await response;
|
||
assert([200, 201].includes(r.status()));
|
||
await page.getByRole('article').filter({ hasText: title }).waitFor();
|
||
await page
|
||
.getByRole('heading', {
|
||
name: 'Novo recurso incompleto',
|
||
exact: true,
|
||
})
|
||
.waitFor();
|
||
return { status: r.status(), id: (await r.json()).id };
|
||
}
|
||
try {
|
||
q.phase('QA target and authorized QA21; no QA18 authentication retest');
|
||
({ ctx, page } = await q.context('colleague', 1440));
|
||
await q.login(page, 'colleague');
|
||
await q.open(page, route);
|
||
if (screenshotOnly) {
|
||
await page.setViewportSize({ width: 390, height: 1000 });
|
||
await edit(names.mz);
|
||
const mz = await savedLocation(names.mz);
|
||
q.proof.recovered_map = await map(mz.payload.district_id);
|
||
const box = page.getByRole('region', {
|
||
name: 'Cobertura cartográfica da seleção',
|
||
exact: true,
|
||
});
|
||
await box.scrollIntoViewIfNeeded();
|
||
assert.equal(
|
||
await box.getByText(/limites oficiais por verificar/).count(),
|
||
0,
|
||
);
|
||
assert.equal(
|
||
await box
|
||
.getByText(
|
||
/Limite administrativo da edição indicada; não representa um local de investimento/,
|
||
)
|
||
.count(),
|
||
2,
|
||
);
|
||
const file = path.join(
|
||
DIR,
|
||
'territory-resource-recovered-map-390-' + q.stamp + '.png',
|
||
);
|
||
await box.screenshot({ path: file });
|
||
q.proof.screenshots.push(
|
||
path
|
||
.relative(path.resolve(__dirname, '..'), file)
|
||
.replaceAll('\\', '/'),
|
||
);
|
||
const p = await props();
|
||
assert(
|
||
!p.assessment.some((issue) => /província|distrito/.test(issue)),
|
||
);
|
||
q.proof.assessment_foreign_hierarchy_not_required = true;
|
||
q.proof.administrative_edition_caption_correct = true;
|
||
q.proof.result = 'passed_read_only_map_screenshot';
|
||
return;
|
||
}
|
||
const baseline = await props();
|
||
q.proof.before = {
|
||
locations: baseline.resources.locations.length,
|
||
accepted_territorial_links: baseline.existingLocations.length,
|
||
};
|
||
q.proof.resources = {};
|
||
let mz = await savedLocation(names.mz);
|
||
if (!mz) {
|
||
const province = await choose('Província', /^Cabo Delgado/);
|
||
const district = await choose('Distrito', /^Ancuabe/);
|
||
q.proof.selection = {
|
||
province,
|
||
district,
|
||
initial_map: await map(district.id),
|
||
};
|
||
q.save();
|
||
await choose('Província', /^Nampula/);
|
||
assert.equal(
|
||
await page
|
||
.getByRole('combobox', { name: 'Distrito', exact: true })
|
||
.inputValue(),
|
||
'',
|
||
);
|
||
assert.equal(
|
||
await page
|
||
.getByRole('combobox', {
|
||
name: 'Posto administrativo',
|
||
exact: true,
|
||
})
|
||
.inputValue(),
|
||
'',
|
||
);
|
||
await choose('Província', /^Cabo Delgado/);
|
||
await choose('Distrito', /^Ancuabe/);
|
||
await page
|
||
.getByLabel('Papel da localização', { exact: true })
|
||
.selectOption('market');
|
||
await page
|
||
.getByLabel('Natureza da relação territorial', { exact: true })
|
||
.selectOption('context');
|
||
await save(names.mz);
|
||
mz = await savedLocation(names.mz);
|
||
}
|
||
assert.equal(mz.payload.country_code, 'MZ');
|
||
assert(mz.payload.province_id && mz.payload.district_id);
|
||
q.proof.resources.mz = {
|
||
id: mz.id,
|
||
version: mz.version,
|
||
payload: mz.payload,
|
||
};
|
||
// API rejection must preserve an already saved resource/version.
|
||
const current = await props();
|
||
assert(ctx.qaConfirmed);
|
||
const rejected = await browserRequest(page, 'POST',
|
||
BASE + route + '/locations/' + mz.id,
|
||
{
|
||
headers: await requestHeaders(),
|
||
data: {
|
||
etag: current.etag,
|
||
version: mz.version,
|
||
creation_key: crypto.randomUUID(),
|
||
reason: 'Ensaio QA de recusa da combinação país e hierarquia incompatíveis.',
|
||
payload: { ...mz.payload, country_code: 'PT' },
|
||
},
|
||
},
|
||
);
|
||
assert.equal(rejected.status(), 422);
|
||
assert((await rejected.json()).errors.country_code);
|
||
const unchanged = await savedLocation(names.mz);
|
||
assert.deepEqual(unchanged.payload, mz.payload);
|
||
assert.equal(unchanged.version, mz.version);
|
||
q.proof.rejected_foreign_hierarchy = {
|
||
status: 422,
|
||
error_field: 'country_code',
|
||
saved_payload_unchanged: true,
|
||
saved_version_unchanged: true,
|
||
};
|
||
q.phase(
|
||
'Foreign-country change clears only the unsaved hierarchy; save a recoverable incomplete location',
|
||
);
|
||
let pt = await savedLocation(names.pt);
|
||
if (!pt) {
|
||
await q.open(page, route);
|
||
await choose('Província', /^Cabo Delgado/);
|
||
await choose('Distrito', /^Ancuabe/);
|
||
await page
|
||
.getByLabel('País da localização', { exact: true })
|
||
.selectOption('PT');
|
||
for (const label of [
|
||
'Província',
|
||
'Distrito',
|
||
'Posto administrativo',
|
||
'Localidade',
|
||
]) {
|
||
const s = page.getByRole('combobox', {
|
||
name: label,
|
||
exact: true,
|
||
});
|
||
assert.equal(await s.inputValue(), '');
|
||
assert(await s.isDisabled());
|
||
}
|
||
assert.equal(
|
||
await page
|
||
.getByRole('region', {
|
||
name: 'Cobertura cartográfica da seleção',
|
||
})
|
||
.count(),
|
||
0,
|
||
);
|
||
await save(names.pt);
|
||
pt = await savedLocation(names.pt);
|
||
}
|
||
assert.equal(pt.payload.country_code, 'PT');
|
||
for (const key of [
|
||
'province_id',
|
||
'district_id',
|
||
'admin_post_id',
|
||
'locality_id',
|
||
])
|
||
assert(!pt.payload[key]);
|
||
q.proof.resources.pt = {
|
||
id: pt.id,
|
||
version: pt.version,
|
||
payload: pt.payload,
|
||
};
|
||
await ctx.close();
|
||
await new Promise((resolve) =>
|
||
setTimeout(resolve, 30000 - (Date.now() % 30000) + 1000),
|
||
);
|
||
q.phase(
|
||
'New QA21 browser context: recover saved paths, map and foreign draft; do not repeat C37',
|
||
);
|
||
({ ctx, page } = await q.context('promoter-new-context', 390));
|
||
await q.login(page, 'colleague');
|
||
await edit(names.mz);
|
||
assert.equal(
|
||
await page
|
||
.getByLabel('País da localização', { exact: true })
|
||
.inputValue(),
|
||
'MZ',
|
||
);
|
||
for (const [label, key] of [
|
||
['Província', 'province_id'],
|
||
['Distrito', 'district_id'],
|
||
])
|
||
assert.equal(
|
||
await page
|
||
.getByRole('combobox', { name: label, exact: true })
|
||
.inputValue(),
|
||
mz.payload[key],
|
||
);
|
||
q.proof.recovered_map = await map(mz.payload.district_id);
|
||
assert(q.proof.recovered_map.rendered_polygon_paths > 0);
|
||
await q.shot(page, 'saved-location-recovered-map-mobile', false);
|
||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||
await page
|
||
.getByRole('article')
|
||
.filter({ hasText: names.mz })
|
||
.getByRole('button', {
|
||
name: 'Ver / editar / restaurar',
|
||
exact: true,
|
||
})
|
||
.click();
|
||
await map(mz.payload.district_id);
|
||
await edit(names.pt);
|
||
assert.equal(
|
||
await page
|
||
.getByLabel('País da localização', { exact: true })
|
||
.inputValue(),
|
||
'PT',
|
||
);
|
||
assert.equal(
|
||
await page
|
||
.getByRole('region', {
|
||
name: 'Cobertura cartográfica da seleção',
|
||
})
|
||
.count(),
|
||
0,
|
||
);
|
||
const final = await props();
|
||
assert.equal(
|
||
final.existingLocations.length,
|
||
baseline.existingLocations.length,
|
||
);
|
||
assert.equal(final.opportunity.status, baseline.opportunity.status);
|
||
q.proof.after = {
|
||
locations: final.resources.locations.length,
|
||
accepted_territorial_links: final.existingLocations.length,
|
||
opportunity_status_unchanged: true,
|
||
};
|
||
await ctx.close();
|
||
q.phase('Different organization QA23 denied view and write');
|
||
({ ctx, page } = await q.context('outsider', 1440));
|
||
await q.login(page, 'outsider');
|
||
assert.equal((await q.open(page, route)).status(), 403);
|
||
assert(ctx.qaConfirmed);
|
||
const denied = await browserRequest(page, 'POST',
|
||
BASE + route + '/locations/' + mz.id,
|
||
{
|
||
headers: await requestHeaders(),
|
||
data: {
|
||
etag: final.etag,
|
||
version: mz.version,
|
||
creation_key: crypto.randomUUID(),
|
||
reason: 'Ensaio sintético de autorização QA de organização diferente.',
|
||
payload: mz.payload,
|
||
},
|
||
},
|
||
);
|
||
assert.equal(denied.status(), 403);
|
||
q.proof.authorization = {
|
||
actor_id: 23,
|
||
view_status: 403,
|
||
write_status: 403,
|
||
};
|
||
q.proof.checks = {
|
||
country_hierarchy_server_rejection: true,
|
||
unsaved_country_change_clears_mozambique_hierarchy: true,
|
||
foreign_incomplete_draft_saved: true,
|
||
new_context_country_path_map_recovery: true,
|
||
parent_change_clears_descendants: true,
|
||
no_accepted_territorial_links_added: true,
|
||
no_publication_or_active_writes: true,
|
||
wrong_organization_denied: true,
|
||
no_qa18_authentication_retest: true,
|
||
};
|
||
q.proof.result = 'passed';
|
||
} catch (error) {
|
||
q.proof.result = 'failed';
|
||
q.proof.error = sanitized(error);
|
||
console.log(JSON.stringify({ error: q.proof.error }));
|
||
process.exitCode = 1;
|
||
} finally {
|
||
q.proof.finished_at = new Date().toISOString();
|
||
q.save();
|
||
await q.browser.close();
|
||
console.log(
|
||
JSON.stringify({
|
||
result: q.proof.result,
|
||
checks: q.proof.checks,
|
||
receipt: path.basename(q.receipt),
|
||
}),
|
||
);
|
||
}
|
||
})();
|